Micron Document
🎖️GitЯра🎖️

Commit 7631db63d0bdff28078438b095c0259306cb6617


Parents : 9947efd
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Date : 2026-06-04T04:33:18-05:00

feat(node): msh.to device hardware links ("I want one" section + Settings directory) (#5714)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Changes

38 files changed, 3551 insertions(+), 6 deletions(-)


Diff

diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 93c51a572c..ff03a5edec 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -186,6 +186,7 @@ codec_2_enabled
codec2_sample_rate
coding_rate
collapse_chart
+collapsed
communicate_off_the_grid
### COMPASS ###
compass_bearing
@@ -316,6 +317,9 @@ device_configuration
device_db_cache_limit
device_db_cache_limit_summary
device_gps
+device_links
+device_links_i_want_one
+device_links_open_in_browser
device_metrics_label_value
device_metrics_log
device_metrics_numeric_value
@@ -433,6 +437,7 @@ event_welcome_hamvention
event_welcome_open_sauce
exchange_position
expand_chart
+expanded
expires
### EXPORT ###
export_configuration

diff --git a/androidApp/src/main/assets/marketplaces.json b/androidApp/src/main/assets/marketplaces.json
new file mode 100644
index 0000000000..49feb5c992
--- /dev/null
+++ b/androidApp/src/main/assets/marketplaces.json
@@ -0,0 +1,126 @@
+{
+ "rokland": {
+ "regions": [
+ "AU",
+ "AT",
+ "BE",
+ "CA",
+ "DK",
+ "EC",
+ "FR",
+ "DE",
+ "IE",
+ "JP",
+ "NL",
+ "NZ",
+ "NO",
+ "PK",
+ "ES",
+ "SE",
+ "CH",
+ "GB",
+ "US"
+ ],
+ "match": "prefix"
+ },
+ "hexaspot": {
+ "regions": [
+ "AT",
+ "BE",
+ "BG",
+ "CY",
+ "CZ",
+ "DE",
+ "DK",
+ "EE",
+ "ES",
+ "FI",
+ "FR",
+ "GR",
+ "HR",
+ "HU",
+ "IE",
+ "IT",
+ "LT",
+ "LU",
+ "LV",
+ "MT",
+ "NL",
+ "NO",
+ "PL",
+ "PT",
+ "RO",
+ "SE",
+ "SI",
+ "SK"
+ ],
+ "match": "prefix"
+ },
+ "aliexpress": {
+ "regions": [],
+ "match": "suffix"
+ },
+ "amazon": {
+ "regions": [
+ "AU",
+ "CA",
+ "FR",
+ "DE",
+ "IE",
+ "JP",
+ "NL",
+ "ES",
+ "SE",
+ "GB",
+ "US"
+ ],
+ "match": "suffix"
+ },
+ "tindie": {
+ "regions": [
+ "US",
+ "CA",
+ "GB",
+ "DE",
+ "FR",
+ "AU",
+ "NL"
+ ],
+ "match": "suffix"
+ },
+ "muzi": {
+ "regions": [
+ "AU",
+ "AT",
+ "BE",
+ "CA",
+ "CZ",
+ "DK",
+ "FI",
+ "FR",
+ "DE",
+ "HK",
+ "IN",
+ "IE",
+ "IL",
+ "IT",
+ "JP",
+ "MY",
+ "NL",
+ "NZ",
+ "NO",
+ "PL",
+ "PT",
+ "SG",
+ "KR",
+ "ES",
+ "SE",
+ "CH",
+ "TW",
+ "AE",
+ "GB",
+ "US"
+ ],
+ "match": "prefix"
+ }
+}
\ No newline at end of file

diff --git a/androidApp/src/main/assets/urls.json b/androidApp/src/main/assets/urls.json
new file mode 100644
index 0000000000..2b02b3fe63
--- /dev/null
+++ b/androidApp/src/main/assets/urls.json
@@ -0,0 +1,1009 @@
+{
+ "Routes": [
+ {
+ "ShortCode": "github",
+ "OriginalUrl": "https://github.com/meshtastic",
+ "Description": "Meshtastic GitHub Organization"
+ },
+ {
+ "ShortCode": "youtube",
+ "OriginalUrl": "https://www.youtube.com/meshtastic",
+ "Description": "Meshtastic YouTube Channel"
+ },
+ {
+ "ShortCode": "reddit",
+ "OriginalUrl": "https://www.reddit.com/r/meshtastic",
+ "Description": "Meshtastic Reddit Community"
+ },
+ {
+ "ShortCode": "docs",
+ "OriginalUrl": "https://meshtastic.org/docs/",
+ "Description": "Meshtastic Documentation"
+ },
+ {
+ "ShortCode": "discord",
+ "OriginalUrl": "https://discord.gg/meshtastic",
+ "Description": "Meshtastic Discord Server"
+ },
+ {
+ "ShortCode": "web",
+ "OriginalUrl": "https://client.meshtastic.org/",
+ "Description": "Meshtastic Web Client"
+ },
+ {
+ "ShortCode": "flash",
+ "OriginalUrl": "https://flasher.meshtastic.org/",
+ "Description": "Meshtastic Web Flasher"
+ },
+ {
+ "ShortCode": "firmware",
+ "OriginalUrl": "https://github.com/meshtastic/firmware",
+ "Description": "Meshtastic Firmware Repository"
+ },
+ {
+ "ShortCode": "android",
+ "OriginalUrl": "https://play.google.com/store/apps/details?id=com.geeksville.mesh",
+ "Description": "Meshtastic Android App"
+ },
+ {
+ "ShortCode": "ios",
+ "OriginalUrl": "https://apple.co/3Auysep",
+ "Description": "Meshtastic iOS App"
+ },
+ {
+ "ShortCode": "rak-collection",
+ "OriginalUrl": "https://store.rakwireless.com/collections/meshtastic?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAKwireless Meshtastic Collection"
+ },
+ {
+ "ShortCode": "rak4631",
+ "OriginalUrl": "https://store.rakwireless.com/products/wisblock-meshtastic-starter-kit?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh RAK4631 Starter Kit"
+ },
+ {
+ "ShortCode": "rak3312",
+ "OriginalUrl": "https://store.rakwireless.com/products/meshtastic-starter-kit-esp32-s3-lora-sx1262?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh ESP32-S3 Starter Kit"
+ },
+ {
+ "ShortCode": "rak3401-1watt",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-1w-booster-starter-kit?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh RAK3401 1W Starter Kit"
+ },
+ {
+ "ShortCode": "rak_wismeshtap",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-tap?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK WisMesh Tap"
+ },
+ {
+ "ShortCode": "rak_wismeshtag",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-tag-meshtastic-gps-lora-tracker-ip66?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK WisMesh Tag"
+ },
+ {
+ "ShortCode": "rokland-wismesh-tag",
+ "OriginalUrl": "https://store.rokland.com/products/wismesh-tag-from-rakwireless-mokosmart-meshtastic-compatible-card-sized-node-us915-mhz",
+ "Description": "Rokland WisMesh Tag"
+ },
+ {
+ "ShortCode": "hexaspot-wismesh-tag",
+ "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/wismesh-tag",
+ "Description": "Hexaspot WisMesh Tag"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-tag",
+ "OriginalUrl": "https://www.aliexpress.com/item/1005009754254701.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "Aliexpress RAK WisMesh Tag"
+ },
+ {
+ "ShortCode": "rak19007",
+ "OriginalUrl": "https://store.rakwireless.com/products/rak19007-wisblock-base-board-2nd-gen?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAKwireless RAK19007 WisBlock Base Board 2nd Gen"
+ },
+ {
+ "ShortCode": "tbeam-s3-core",
+ "OriginalUrl": "https://lilygo.cc/products/t-beam-supreme-meshtastic",
+ "Description": "T-Beam Supreme"
+ },
+ {
+ "ShortCode": "t-echo",
+ "OriginalUrl": "https://lilygo.cc/products/t-echo-meshtastic",
+ "Description": "T-Echo"
+ },
+ {
+ "ShortCode": "t-watch-s3",
+ "OriginalUrl": "https://lilygo.cc/products/t-watch-s3",
+ "Description": "T-Watch S3"
+ },
+ {
+ "ShortCode": "t-deck",
+ "OriginalUrl": "https://lilygo.cc/products/t-deck-meshtastic",
+ "Description": "T-Deck"
+ },
+ {
+ "ShortCode": "tlora-t3s3-v1",
+ "OriginalUrl": "https://lilygo.cc/products/t3-s3-meshtastic",
+ "Description": "T3S3"
+ },
+ {
+ "ShortCode": "heltec-mesh-node-t114",
+ "OriginalUrl": "https://heltec.org/project/mesh-node-t114/",
+ "Description": "Mesh Node T114"
+ },
+ {
+ "ShortCode": "heltec-vision-master-e213",
+ "OriginalUrl": "https://heltec.org/project/vision-master-e213/",
+ "Description": "Vision Master E213"
+ },
+ {
+ "ShortCode": "heltec-vision-master-e290",
+ "OriginalUrl": "https://heltec.org/project/vision-master-e290/",
+ "Description": "Vision Master E290"
+ },
+ {
+ "ShortCode": "heltec-vision-master-t190",
+ "OriginalUrl": "https://heltec.org/project/vision-master-t190/",
+ "Description": "Vision Master T190"
+ },
+ {
+ "ShortCode": "heltec-wireless-tracker",
+ "OriginalUrl": "https://heltec.org/project/wireless-tracker/",
+ "Description": "Wireless Tracker"
+ },
+ {
+ "ShortCode": "heltec-wireless-tracker-v2",
+ "OriginalUrl": "https://heltec.org/project/wireless-tracker-v2/",
+ "Description": "Wireless Tracker V2"
+ },
+ {
+ "ShortCode": "heltec-wireless-paper",
+ "OriginalUrl": "https://heltec.org/project/wireless-paper/",
+ "Description": "Wireless Paper"
+ },
+ {
+ "ShortCode": "heltec-ht62-esp32c3-sx1262",
+ "OriginalUrl": "https://heltec.org/project/ht-ct62/",
+ "Description": "HT-CT62"
+ },
+ {
+ "ShortCode": "wio-tracker-wm1110",
+ "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-1110-Dev-Kit-for-Meshtastic.html",
+ "Description": "Wio Tracker WM1110 Dev Kit"
+ },
+ {
+ "ShortCode": "tracker-t1000-e",
+ "OriginalUrl": "https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html",
+ "Description": "SenseCAP Card Tracker T1000-E"
+ },
+ {
+ "ShortCode": "tracker-t1000-e-aliexpress",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256807287978389.html",
+ "Description": "SenseCAP Card Tracker T1000-E Aliexpress"
+ },
+ {
+ "ShortCode": "tracker-t1000-e-amazon",
+ "OriginalUrl": "https://www.amazon.com/dp/B0DJ6KGXKB",
+ "Description": "SenseCAP Card Tracker T1000-E Amazon"
+ },
+ {
+ "ShortCode": "seeed-sensecap-indicator",
+ "OriginalUrl": "https://www.seeedstudio.com/SenseCAP-Indicator-D1L-for-Meshtastic-p-6304.html",
+ "Description": "SenseCAP Indicator"
+ },
+ {
+ "ShortCode": "station-g2",
+ "OriginalUrl": "https://shop.uniteng.com/product/meshtastic-mesh-device-station-edition/",
+ "Description": "Station G2"
+ },
+ {
+ "ShortCode": "rak2560",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-meshtastic-solar-repeater?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Repeater"
+ },
+ {
+ "ShortCode": "heltec-v3",
+ "OriginalUrl": "https://heltec.org/project/wifi-lora-32-v3/",
+ "Description": "LoRa32 V3"
+ },
+ {
+ "ShortCode": "heltec-wsl-v3",
+ "OriginalUrl": "https://heltec.org/project/wireless-stick-lite-v2/",
+ "Description": "WSL V3"
+ },
+ {
+ "ShortCode": "heltec-v4",
+ "OriginalUrl": "https://heltec.org/project/wifi-lora-32-v4/",
+ "Description": "LoRa32 V4"
+ },
+ {
+ "ShortCode": "seeed-xiao-s3",
+ "OriginalUrl": "https://www.seeedstudio.com/Wio-SX1262-with-XIAO-ESP32S3-p-5982.html",
+ "Description": "XIAO ESP32-S3 + Wio-SX1262 Kit"
+ },
+ {
+ "ShortCode": "tlora-t3s3-epaper",
+ "OriginalUrl": "https://lilygo.cc/products/t3-s3-meshtastic",
+ "Description": "T3S3"
+ },
+ {
+ "ShortCode": "ht-ct62",
+ "OriginalUrl": "https://heltec.org/project/ht-ct62/",
+ "Description": "HT-CT62"
+ },
+ {
+ "ShortCode": "seeed_xiao_nrf52840_kit",
+ "OriginalUrl": "https://www.seeedstudio.com/XIAO-nRF52840-Wio-SX1262-Kit-for-Meshtastic-p-6400.html",
+ "Description": "XIAO nRF52840 & Wio-SX1262 Kit"
+ },
+ {
+ "ShortCode": "seeed_xiao_nrf52840_kit_aliexpress",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256808574469954.html",
+ "Description": "XIAO nRF52840 & Wio-SX1262 Kit Aliexpress"
+ },
+ {
+ "ShortCode": "thinknode_m1",
+ "OriginalUrl": "https://www.elecrow.com/thinknode-m1-meshtastic-lora-signal-transceiver-powered-by-nrf52840-with-154-screen-support-gps.html",
+ "Description": "ThinkNode M1"
+ },
+ {
+ "ShortCode": "thinknode_m2",
+ "OriginalUrl": "https://www.elecrow.com/thinknode-m2-meshtastic-lora-signal-transceiver-powered-by-esp32-s3-with-1-3-oled-display.html",
+ "Description": "ThinkNode M2"
+ },
+ {
+ "ShortCode": "thinknode_m3",
+ "OriginalUrl": "https://www.elecrow.com/thinknode-m3-meshtastic-tracker-with-gps-wifi-ble-function-for-indoor-and-outdoor-positioning.html",
+ "Description": "ThinkNode M3"
+ },
+ {
+ "ShortCode": "thinknode_m5",
+ "OriginalUrl": "https://www.elecrow.com/thinknode-m5-meshtastic-lora-signal-transceiver-esp32-s3-1-54-screen-gps-function.html",
+ "Description": "ThinkNode M5"
+ },
+ {
+ "ShortCode": "thinknode_m4",
+ "OriginalUrl": "https://www.elecrow.com/thinknode-m4-power-bank-lora-device-with-meshtastic-lora-tracker-function-powered-by-nrf52840.html",
+ "Description": "ThinkNode M4"
+ },
+ {
+ "ShortCode": "thinknode_m6",
+ "OriginalUrl": "https://www.elecrow.com/thinknode-m6-outdoor-solar-power-for-meshtastic-powered-by-nrf52840-supports-gps.html",
+ "Description": "ThinkNode M6"
+ },
+ {
+ "ShortCode": "heltec-mesh-pocket-10000",
+ "OriginalUrl": "https://heltec.org/project/meshpocket/",
+ "Description": "MeshPocket"
+ },
+ {
+ "ShortCode": "seeed_solar_node",
+ "OriginalUrl": "https://www.seeedstudio.com/SenseCAP-Solar-Node-P1-Pro-for-Meshtastic-LoRa-p-6412.html",
+ "Description": "SenseCAP Solar Node P1 Pro"
+ },
+ {
+ "ShortCode": "seeed_solar_node_aliexpress",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256808731224053.html",
+ "Description": "SenseCAP Solar Node P1 Pro Aliexpress"
+ },
+ {
+ "ShortCode": "seeed_solar_node_amazon",
+ "OriginalUrl": "https://www.amazon.com/dp/B0FMDHBWX8",
+ "Description": "SenseCAP Solar Node P1 Pro Amazon"
+ },
+ {
+ "ShortCode": "elecrow-adv-35-tft",
+ "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-3-5-hmi-esp32-ai-display-for-meshtastic-320x240-ips-artificial-intelligent-screen.html",
+ "Description": "CrowPanel 3.5"
+ },
+ {
+ "ShortCode": "elecrow-adv1-43-50-70-tft",
+ "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-4-3-hmi-ai-screen-for-meshtastic-esp32-800x480-ips-touch-artificial-intelligent-display-2.html",
+ "Description": "CrowPanel 4.3"
+ },
+ {
+ "ShortCode": "elecrow-adv-24-28-tft",
+ "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-2-4-hmi-ai-display-for-meshtastic-esp32-320x240-ips-artificial-intelligent-touchscreen.html",
+ "Description": "CrowPanel 2.4"
+ },
+ {
+ "ShortCode": "elecrow-adv-28-tft",
+ "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-2-8-hmi-ai-display-for-meshtastic-esp32-320x240-artificial-ips-intelligent-touchscreen.html",
+ "Description": "CrowPanel 2.8"
+ },
+ {
+ "ShortCode": "elecrow-adv1-50-tft",
+ "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-5inch-hmi-esp32-ai-display-800x480-ips-artificial-intelligent-touch-screen-support-meshtastic.html",
+ "Description": "CrowPanel 5.0"
+ },
+ {
+ "ShortCode": "elecrow-adv1-70-tft",
+ "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-7-0-hmi-esp32-ai-display-800x480-artificial-intelligent-ips-touch-screen-for-meshtastic.html",
+ "Description": "CrowPanel 7.0"
+ },
+ {
+ "ShortCode": "seeed_wio_tracker_L1",
+ "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-L1-Pro-p-6454.html",
+ "Description": "Wio Tracker L1"
+ },
+ {
+ "ShortCode": "seeed_wio_tracker_L1_aliexpress",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256809394050623.html",
+ "Description": "Wio Tracker L1 Aliexpress"
+ },
+ {
+ "ShortCode": "seeed_wio_tracker_L1_amazon",
+ "OriginalUrl": "https://www.amazon.com/dp/B0FNCS5ST1",
+ "Description": "Wio Tracker L1 Amazon"
+ },
+ {
+ "ShortCode": "nano-g2-ultra",
+ "OriginalUrl": "https://shop.uniteng.com/product/meshtastic-mesh-device-nano-g2-ultra/",
+ "Description": "Nano G2 Ultra"
+ },
+ {
+ "ShortCode": "rak11310",
+ "OriginalUrl": "https://store.rakwireless.com/products/wisblock-rp2040-starter-kit-for-meshtastic?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK11310"
+ },
+ {
+ "ShortCode": "rokland-rak11310",
+ "OriginalUrl": "https://store.rakwireless.com/products/wisblock-rp2040-starter-kit-for-meshtastic?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "Rokland RAKwireless RAK11310 WisBlock RP2040 Core Module"
+ },
+ {
+ "ShortCode": "station-g2-tindie",
+ "OriginalUrl": "https://www.tindie.com/products/neilhao/meshtastic-mesh-device-station-g2/",
+ "Description": "Station G2 Tindie Listing"
+ },
+ {
+ "ShortCode": "nano-g2-ultra-tindie",
+ "OriginalUrl": "https://www.tindie.com/products/neilhao/meshtastic-mesh-device-nano-g2-ultra/",
+ "Description": "Nano G2 Ultra Tindie Listing"
+ },
+ {
+ "ShortCode": "t-deck-plus",
+ "OriginalUrl": "https://lilygo.cc/products/t-deck-plus-meshtastic",
+ "Description": "T-Deck Plus"
+ },
+ {
+ "ShortCode": "rokland-meshtastic-starter-kit",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-meshtastic-starter-kit",
+ "Description": "Rokland Meshtastic Starter Kit"
+ },
+ {
+ "ShortCode": "rokland-t-deck-base",
+ "OriginalUrl": "https://store.rokland.com/products/lilygo-t-deck-portable-microcontroller-programmer-lora-915-mhz-h642?variant=41000826372179",
+ "Description": "Rokland T-Deck Base"
+ },
+ {
+ "ShortCode": "rokland-t-deck-complete",
+ "OriginalUrl": "https://store.rokland.com/products/lilygo-t-deck-portable-microcontroller-programmer-lora-915-mhz-h642?variant=42122265690195",
+ "Description": "Rokland T-Deck Complete"
+ },
+ {
+ "ShortCode": "rokland-t-deck-plus",
+ "OriginalUrl": "https://store.rokland.com/products/lilygo-t-deck-portable-microcontroller-programmer-lora-915-mhz-h642?variant=42283977834579",
+ "Description": "Rokland T-Deck Plus"
+ },
+ {
+ "ShortCode": "rokland-t-echo",
+ "OriginalUrl": "https://store.rokland.com/products/lilygo-ttgo-meshtastic-t-echo-white-lora-sx1262-wireless-module-915mhz-nrf52840-gps-for-arduino?ref=8Bb2mUO5i-jKwt",
+ "Description": "Rokland T-Echo"
+ },
+ {
+ "ShortCode": "rokland-t-echo-bme280",
+ "OriginalUrl": "https://store.rokland.com/products/lilygo-ttgo-meshtastic-t-echo-white-bme280-lora-sx1262-wireless-module-915mhz-nrf52840-gps-rtc-nfc-for-arduino?ref=8Bb2mUO5i-jKwt",
+ "Description": "Rokland T-Echo with BME280"
+ },
+ {
+ "ShortCode": "rokland-rak19007",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-base-board-2nd-gen-rak19007-ver-b-pid-110082",
+ "Description": "Rokland RAKwireless RAK19007 WisBlock Base Board 2nd Gen"
+ },
+ {
+ "ShortCode": "hexaspot-rak19007",
+ "OriginalUrl": "https://hexaspot.com/collections/rakwireless-wisblock-base/products/rakwireless-rak19007-wisblock-base-board-2nd-gen",
+ "Description": "Hexaspot RAKwireless RAK19007 WisBlock Base Board 2nd Gen"
+ },
+ {
+ "ShortCode": "rokland-starter-kit",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-meshtastic-starter-kit",
+ "Description": "Rokland RAKwireless 4631 Starter Kit"
+ },
+ {
+ "ShortCode": "hexaspot-starter-kit",
+ "OriginalUrl": "https://hexaspot.com/collections/wisblock-kits/products/wisblock-starter-kit-wisblock-basic-kit",
+ "Description": "Hexaspot RAKwireless 4631 Starter Kit"
+ },
+ {
+ "ShortCode": "aliexpress-rak1921",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256801470591730.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK1921 OLED Display (AliExpress)"
+ },
+ {
+ "ShortCode": "rak1921",
+ "OriginalUrl": "https://store.rakwireless.com/products/rak1921-oled-display-panel?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK1921 OLED Display (RAK Store)"
+ },
+ {
+ "ShortCode": "rokland-rak1921",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-oled-display-rak1921-pid-110004",
+ "Description": "Rokland RAK1921 WisBlock OLED Display"
+ },
+ {
+ "ShortCode": "muzi-rak1921",
+ "OriginalUrl": "https://muzi.works/products/rak-oled-display-ssd1306",
+ "Description": "Muzi Works RAK1921 OLED Display SSD1306"
+ },
+ {
+ "ShortCode": "aliexpress-rak14000",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256803245280485.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK14000 E-Ink Display (AliExpress)"
+ },
+ {
+ "ShortCode": "rak14000",
+ "OriginalUrl": "https://store.rakwireless.com/products/wisblock-epd-module-rak14000?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK14000 E-Ink Display (RAK Store)"
+ },
+ {
+ "ShortCode": "rokland-rak14000",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-epd-module-rak14000-pid-110024",
+ "Description": "Rokland RAK14000 WisBlock E-Ink Display"
+ },
+ {
+ "ShortCode": "aliexpress-rak12500",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256802312416216.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK12500 (AliExpress)"
+ },
+ {
+ "ShortCode": "rak12500",
+ "OriginalUrl": "https://store.rakwireless.com/products/wisblock-gnss-location-module-rak12500?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK12500 (RAK Store)"
+ },
+ {
+ "ShortCode": "rak13300",
+ "OriginalUrl": "https://store.rakwireless.com/products/rak13300-wisblock-lpwan?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK13300 LPWAN Module (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-rak13002",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256802904688489.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK13002 IO Module (AliExpress)"
+ },
+ {
+ "ShortCode": "rak13002",
+ "OriginalUrl": "https://store.rakwireless.com/products/adapter-module-rak13002?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK13002 IO Module (RAK Store)"
+ },
+ {
+ "ShortCode": "rokland-rak13002",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak13002-wisblock-io-adapter-module",
+ "Description": "Rokland RAK13002 WisBlock IO Adapter Module"
+ },
+ {
+ "ShortCode": "muzi-rak13002",
+ "OriginalUrl": "https://muzi.works/products/rak-io-module",
+ "Description": "Muzi Works RAK13002 IO Module"
+ },
+ {
+ "ShortCode": "rak6421",
+ "OriginalUrl": "https://store.rakwireless.com/products/meshtastic-raspberry-pi-hat-rak6421?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Pi Hat RAK6421 (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-rak18001",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256802312587439.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK18001 RAK Buzzer (AliExpress)"
+ },
+ {
+ "ShortCode": "rak18001",
+ "OriginalUrl": "https://store.rakwireless.com/products/wisblock-buzzer-module-rak18001?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK18001 RAK Buzzer (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-rak1901",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256801444571922.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK1901 Temperature and Humidity Sensor (AliExpress)"
+ },
+ {
+ "ShortCode": "rak1901",
+ "OriginalUrl": "https://store.rakwireless.com/products/rak1901-shtc3-temperature-humidity-sensor?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK1901 Temperature and Humidity Sensor (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-rak1902",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256801445721072.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK-1902 Barometric Pressure Sensor (AliExpress)"
+ },
+ {
+ "ShortCode": "rak1902",
+ "OriginalUrl": "https://store.rakwireless.com/products/rak1902-kps22hb-barometric-pressure-sensor?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK-1902 Barometric Pressure Sensor (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-rak1906",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256801453209668.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK1906 Environment Sensor (AliExpress)"
+ },
+ {
+ "ShortCode": "rak1906",
+ "OriginalUrl": "https://store.rakwireless.com/products/rak1906-bme680-environment-sensor?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK1906 Environment Sensor (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-rak12002",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256803919249064.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK12002 WisBlock RTC Module (AliExpress)"
+ },
+ {
+ "ShortCode": "rak12002",
+ "OriginalUrl": "https://store.rakwireless.com/products/rtc-module-rak12002?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK12002 WisBlock RTC Module (RAK Store)"
+ },
+ {
+ "ShortCode": "rokland-rak12002",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak12002-rtc-module-micro-crystal-rv-3028-c7-pid-100032",
+ "Description": "Rokland RAK12002 RTC Module Micro Crystal RV-3028-C7"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-pocket-v2",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256808087883682.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Pocket V2 (AliExpress)"
+ },
+ {
+ "ShortCode": "rokland-wismesh-pocket-v2",
+ "OriginalUrl": "https://store.rokland.com/products/wismesh-pocket",
+ "Description": "WisMesh Pocket V2 (Rokland)"
+ },
+ {
+ "ShortCode": "hexaspot-wismesh-pocket-v2",
+ "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/wismesh-pocket-v2-ready-to-use-meshtastic-device",
+ "Description": "WisMesh Pocket V2 (Hexaspot)"
+ },
+ {
+ "ShortCode": "wismesh-pocket-v2",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-pocket?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Pocket V2 (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-pocket-mini",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256807998160830.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Pocket Mini (Rokland)"
+ },
+ {
+ "ShortCode": "rokland-wismesh-pocket-mini",
+ "OriginalUrl": "https://store.rokland.com/products/rakwireless-wismesh-pocket-mini-all-in-one-meshtastic-handheld-915-mhz-radio-with-lora-antenna",
+ "Description": "WisMesh Pocket Mini (Rokland)"
+ },
+ {
+ "ShortCode": "wismesh-pocket-mini",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-pocket-mini?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Pocket Mini (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-rak19026",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256808063797462.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Baseboard (AliExpress)"
+ },
+ {
+ "ShortCode": "rokland-rak19026",
+ "OriginalUrl": "https://store.rokland.com/products/rakwireless-wismesh-baseboard-rak19026-oled-mounted-gnss-motion-sensor-pid-115125",
+ "Description": "WisMesh Baseboard (Rokland)"
+ },
+ {
+ "ShortCode": "rak19026",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-baseboard-rak19026?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Baseboard (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-tap",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256808097004202.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK WisMesh Tap (AliExpress)"
+ },
+ {
+ "ShortCode": "aliexpress-board-one",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256802139951068.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK WisMesh Board ONE (AliExpress)"
+ },
+ {
+ "ShortCode": "board-one",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-board-one-meshtastic-node?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK WisMesh Board ONE (RAK Store)"
+ },
+ {
+ "ShortCode": "rokland-board-one",
+ "OriginalUrl": "https://store.rokland.com/products/rakwireless-wismesh-b1-board",
+ "Description": "Rokland WisMesh Board ONE (US915 MHz)"
+ },
+ {
+ "ShortCode": "wismesh-repeater",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-meshtastic-solar-repeater?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Repeater (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-repeater",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256808393658502.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Repeater (AliExpress)"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-repeater-mini",
+ "OriginalUrl": "https://www.aliexpress.com/item/2251832722300348.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Repeater Mini (AliExpress)"
+ },
+ {
+ "ShortCode": "hexaspot-wismesh-repeater-mini",
+ "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/wismesh-repeater-mini",
+ "Description": "WisMesh Repeater Mini (Hexaspot)"
+ },
+ {
+ "ShortCode": "wismesh-repeater-mini",
+ "OriginalUrl": "https://store.rakwireless.com/products/wishmesh-meshtastic-solar-repeater-mini?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Repeater Mini (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-ethernet-gateway",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256801470547683.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Ethernet MQTT Gateway (AliExpress)"
+ },
+ {
+ "ShortCode": "wismesh-ethernet-gateway",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-ethernet-gateway?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Ethernet MQTT Gateway (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-wifi-gateway",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256802139923708.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh WiFi MQTT Gateway (AliExpress)"
+ },
+ {
+ "ShortCode": "wismesh-wifi-gateway",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-wifi-gateway?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh WiFi MQTT Gateway (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-board-one-pocket",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256802139951068.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK WisMesh Board ONE Pocket (AliExpress)"
+ },
+ {
+ "ShortCode": "board-one-pocket",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-board-one-pocket-meshtastic-node?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK WisMesh Board ONE Pocket (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-unify-enclosure",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256808182747014.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Unify Enclosure (AliExpress)"
+ },
+ {
+ "ShortCode": "wismesh-unify-enclosure",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-unify-enclosure?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Unify Enclosure (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-antenna",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256808177346156.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Antenna (AliExpress)"
+ },
+ {
+ "ShortCode": "wismesh-antenna",
+ "OriginalUrl": "https://store.rakwireless.com/products/wismesh-antenna?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh Antenna (RAK Store)"
+ },
+ {
+ "ShortCode": "muzi-rak4631",
+ "OriginalUrl": "https://muzi.works/products/rak-wisblock-meshtastic-starter-kit-us915",
+ "Description": "Muzi RAK4631 Starter Kit"
+ },
+ {
+ "ShortCode": "aliexpress-rak19007",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256803957557617.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK19007 (AliExpress)"
+ },
+ {
+ "ShortCode": "aliexpress-starter-kit",
+ "OriginalUrl": "https://www.aliexpress.com/item/1005006901039995.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "WisMesh RAK4631 Starter Kit (AliExpress)"
+ },
+ {
+ "ShortCode": "rak19003",
+ "OriginalUrl": "https://store.rakwireless.com/products/wisblock-base-board-rak19003?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK19003 (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-rak19003",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256803225234826.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK19003 (AliExpress)"
+ },
+ {
+ "ShortCode": "rak19001",
+ "OriginalUrl": "https://store.rakwireless.com/products/rak19001-wisblock-dual-io-base-board?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK19001 WisBlock Dual IO Base Board (RAK Store)"
+ },
+ {
+ "ShortCode": "aliexpress-rak19001",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256803962043191.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK19001 WisBlock Dual IO Base Board (AliExpress)"
+ },
+ {
+ "ShortCode": "rokland-19003",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-mini-base-board-rak19003-ver-b-pid-306024",
+ "Description": "Rokland WisBlock Mini Base Board RAK19003 (Ver B)"
+ },
+ {
+ "ShortCode": "hexaspot-19003",
+ "OriginalUrl": "https://hexaspot.com/collections/rakwireless-wisblock-base/products/rakwireless-rak19003-wisblock-mini-base-board",
+ "Description": "Hexaspot WisBlock Mini Base Board RAK19003 (Ver B)"
+ },
+ {
+ "ShortCode": "rokland-19001",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-dual-io-base-board-rak19001-pid-110081",
+ "Description": "Rokland WisBlock Dual IO Base Board RAK19001"
+ },
+ {
+ "ShortCode": "hexaspot-19001",
+ "OriginalUrl": "https://hexaspot.com/collections/rakwireless-wisblock-base/products/rakwireless-rak19001-wisblock-dual-io-base-board",
+ "Description": "Hexaspot WisBlock Dual IO Base Board RAK19001"
+ },
+ {
+ "ShortCode": "rokland-4631",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak4631-nordic-nrf52840-ble-core-module-for-lorawan-with-lora-sx1262",
+ "Description": "Rokland RAK4631 Nordic nRF52840 BLE Core Module for LoRaWAN with LoRa SX1262"
+ },
+ {
+ "ShortCode": "hexaspot-4631",
+ "OriginalUrl": "https://hexaspot.com/collections/wisblock-kits/products/wisblock-meshtastic-starter-kit-eu868-the-basic-rak4631-meshtastic-kit-for-lora",
+ "Description": "Hexaspot RAK4631 Nordic nRF52840 BLE Core Module for LoRaWAN with LoRa SX1262"
+ },
+ {
+ "ShortCode": "aliexpress-rak4631",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256801470104151.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK4631 Nordic nRF52840 BLE Core Module (AliExpress)"
+ },
+ {
+ "ShortCode": "rakwireless-4631",
+ "OriginalUrl": "https://store.rakwireless.com/products/rak4631-lpwan-node?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK4631 Nordic nRF52840 BLE Core Module (AliExpress)"
+ },
+ {
+ "ShortCode": "rakwireless-rak11310",
+ "OriginalUrl": "https://store.rakwireless.com/products/rak11310-wisblock-lpwan-module?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK11310 RP2040 Core Module)"
+ },
+ {
+ "ShortCode": "rakwireless-rak3312",
+ "OriginalUrl": "https://store.rakwireless.com/products/wisblock-core-module-rak3312-lora-wifi-ble",
+ "Description": "RAK3312 ESP32-S3 Core Module"
+ },
+ {
+ "ShortCode": "hexaspot-rak3312",
+ "OriginalUrl": "https://hexaspot.com/collections/rakwireless-wisblock/products/espressif-esp32-s3-wifi-ble-dual-core-module-for-lorawan%C2%AE-with-lora-sx1262",
+ "Description": "Hexaspot RAK3312 ESP32-S3 Core Module"
+ },
+ {
+ "ShortCode": "rokland-rak3312",
+ "OriginalUrl": "https://store.rokland.com/products/rak3312-espressif-esp32-s3-wifi-ble-dual-core-module-for-lorawan-with-lora-sx1262-116208",
+ "Description": "Rokland RAK3312 ESP32-S3 Core Module"
+ },
+ {
+ "ShortCode": "rokland-rak3312-starter-kit",
+ "OriginalUrl": "https://store.rokland.com/products/wismesh-rak3312-starter-kit-with-meshtastic-firmware",
+ "Description": "Rokland RAK3312 ESP32-S3 Starter Kit"
+ },
+ {
+ "ShortCode": "aliexpress-rak11310",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256803225175784.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAK11310 RP2040 Core Module (AliExpress)"
+ },
+ {
+ "ShortCode": "rokland-1901",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak1901-temperature-and-humidity-sensor-sensirion-shtc3-pid-100001",
+ "Description": "Rokland RAK1901 Temperature and Humidity Sensor"
+ },
+ {
+ "ShortCode": "rokland-1902",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak1902-barometric-pressure-sensor-stmicroelectronics-lps22hb-100010-2-pack",
+ "Description": "Rokland RAK1902 Barometric Pressure Sensor"
+ },
+ {
+ "ShortCode": "rokland-1906",
+ "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak1906-wisblock-environment-sensor-bosch-bme680",
+ "Description": "Rokland RAK1906 WisBlock Environment Sensor"
+ },
+ {
+ "ShortCode": "aliexpress-wismesh-tap",
+ "OriginalUrl": "https://www.aliexpress.com/item/3256808097004202.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAKwireless WisMesh Tap (AliExpress)"
+ },
+ {
+ "ShortCode": "rokland-wismesh-tap",
+ "OriginalUrl": "https://store.rokland.com/products/rakwireless-wismesh-tap-touchscreen-915-mhz-handheld-or-mountable-unit-lora-gps",
+ "Description": "RAKwireless WisMesh Tap (Rokland)"
+ },
+ {
+ "ShortCode": "rakdap1",
+ "OriginalUrl": "https://store.rakwireless.com/products/daplink-tool?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab",
+ "Description": "RAKwireless RAKDAP1 Debug and Flash Tool"
+ },
+ {
+ "ShortCode": "rokland-heltec-wsl-v3",
+ "OriginalUrl": "https://store.rokland.com/collections/heltec-products/products/heltec-wireless-stick-litev3-902-928-mhz/",
+ "Description": "Rokland WSL V3"
+ },
+ {
+ "ShortCode": "aliexpress-heltec-wsl-v3",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256807466584635.htm",
+ "Description": "Aliexpress WSL V3"
+ },
+ {
+ "ShortCode": "rokland-heltec-wireless-tracker",
+ "OriginalUrl": "https://store.rokland.com/collections/heltec-products/products/heltec-wireless-tracker-v1-1-wi-fi-lora-bt-gnss/",
+ "Description": "Rokland Wireless Tracker"
+ },
+ {
+ "ShortCode": "aliexpress-heltec-wireless-tracker",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256805495189423.html",
+ "Description": "Aliexpress Wireless Tracker"
+ },
+ {
+ "ShortCode": "aliexpress-heltec-wireless-paper",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256805461611876.html",
+ "Description": "Aliexpress Wireless Paper"
+ },
+ {
+ "ShortCode": "rokland-heltec-wireless-paper",
+ "OriginalUrl": "https://store.rokland.com/collections/heltec-products/products/heltec-wireless-paper-wi-fi-lora-bt/",
+ "Description": "Rokland Wireless Paper"
+ },
+ {
+ "ShortCode": "muzi-heltec-mesh-node-t114",
+ "OriginalUrl": "https://muzi.works/products/heltec-mesh-node-t114/",
+ "Description": "MuziWorks Mesh Node T114"
+ },
+ {
+ "ShortCode": "aliexpress-heltec-mesh-node-t114",
+ "OriginalUrl": "https://www.aliexpress.com/item/1005007460963705.html",
+ "Description": "Aliexpress Mesh Node T114"
+ },
+ {
+ "ShortCode": "aliexpress-heltec-vision-master-e213",
+ "OriginalUrl": "https://www.aliexpress.com/item/1005007209756502.html",
+ "Description": "Aliexpress Vision Master E213"
+ },
+ {
+ "ShortCode": "aliexpress-heltec-vision-master-e290",
+ "OriginalUrl": "https://www.aliexpress.com/item/1005007234361986.html",
+ "Description": "Aliexpress Vision Master E290"
+ },
+ {
+ "ShortCode": "aliexpress-heltec-vision-master-t190",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256807135629435.html",
+ "Description": "Aliexpress Vision Master T190"
+ },
+ {
+ "ShortCode": "seeed-wio-tracker-l1-oled",
+ "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-L1-p-6453.html",
+ "Description": "Wio Tracker L1 (with OLED)"
+ },
+ {
+ "ShortCode": "seeed-wio-tracker-l1-oled_aliexpress",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256809320083189.html",
+ "Description": "Wio Tracker L1 (with OLED)"
+ },
+ {
+ "ShortCode": "seeed_wio_tracker_L1_eink",
+ "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-L1-E-ink-p-6456.html",
+ "Description": "Wio Tracker L1 (with E-Ink)"
+ },
+ {
+ "ShortCode": "seeed_wio_tracker_L1_eink_amazon",
+ "OriginalUrl": "https://www.amazon.com/dp/B0FJWT5FYW",
+ "Description": "Wio Tracker L1 (with E-Ink) Amazon"
+ },
+ {
+ "ShortCode": "seeed-wio-tracker-l1-lite",
+ "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-L1-Lite-p-6455.html",
+ "Description": "Wio Tracker L1 Lite (no display)"
+ },
+ {
+ "ShortCode": "seeed_solar_node_p1",
+ "OriginalUrl": "https://www.seeedstudio.com/SenseCAP-Solar-Node-P1-for-Meshtastic-LoRa-p-6425.html",
+ "Description": "SenseCAP Solar Node P1"
+ },
+ {
+ "ShortCode": "seeed_solar_node_p1_aliexpress",
+ "OriginalUrl": "https://www.aliexpress.us/item/3256808731224053.html",
+ "Description": "SenseCAP Solar Node P1 Aliexpress"
+ },
+ {
+ "ShortCode": "android-closed-test",
+ "OriginalUrl": "https://forms.gle/3dZCSTQWRbMSHkPd6",
+ "Description": "Android Closed Test Form"
+ },
+ {
+ "ShortCode": "t-deck-pro",
+ "OriginalUrl": "https://lilygo.cc/products/t-deck-pro-meshtastic",
+ "Description": "LilyGo T-Deck Pro"
+ },
+ {
+ "ShortCode": "rak4631_nomadstar_meteor_pro",
+ "OriginalUrl": "https://nomadstar.ch/meteor-pro/",
+ "Description": "NomadStar Meteor Pro"
+ },
+ {
+ "ShortCode": "muziworks",
+ "OriginalUrl": "https://muzi.works/",
+ "Description": "muzi WORKS Homepage"
+ },
+ {
+ "ShortCode": "r1-neo",
+ "OriginalUrl": "https://muzi.works/products/r1-neo-complete-meshtastic-device",
+ "Description": "muzi WORKS R1 Neo"
+ },
+ {
+ "ShortCode": "muzi-base",
+ "OriginalUrl": "https://muzi.works/pages/base",
+ "Description": "muzi WORKS Base System"
+ },
+ {
+ "ShortCode": "muzi-base-uno",
+ "OriginalUrl": "https://muzi.works/products/base-uno",
+ "Description": "muzi WORKS Base Uno"
+ },
+ {
+ "ShortCode": "muzi-base-duo",
+ "OriginalUrl": "https://muzi.works/products/base-duo",
+ "Description": "muzi WORKS Base Duo"
+ },
+ {
+ "ShortCode": "muzi-base-super-io",
+ "OriginalUrl": "https://muzi.works/products/super-io",
+ "Description": "muzi WORKS Base Super IO"
+ },
+ {
+ "ShortCode": "ttc-tickets",
+ "OriginalUrl": "https://www.thethingsconference.com/partner-invitations/recgog1edgosiv3b8",
+ "Description": "The Things Conference Tickets"
+ },
+ {
+ "ShortCode": "rokland-atlavox-makers-market",
+ "OriginalUrl": "https://store.rokland.com/products/atlavox-beacon-solar-meshtastic-node-w-n-female-antenna",
+ "Description": "Rokland Atlavox Makers Market"
+ },
+ {
+ "ShortCode": "rokland-tlora-pager",
+ "OriginalUrl": "https://store.rokland.com/products/lilygo-t-lora-pager-us-915-mhz-lora-esp32-s3-handheld-aiot-programmable-development-device-k257-01",
+ "Description": "Rokland T-Lora Pager"
+ },
+ {
+ "ShortCode": "tlora-pager",
+ "OriginalUrl": "https://lilygo.cc/products/t-lora-pager-meshtastic",
+ "Description": "T-Lora Pager"
+ },
+ {
+ "ShortCode": "hexaspot",
+ "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products",
+ "Description": "Hexaspot Meshtastic Products"
+ },
+ {
+ "ShortCode": "ew26",
+ "OriginalUrl": "https://meshtastic.com/ew26",
+ "Description": "embeddedworld26 event page"
+ },
+ {
+ "ShortCode": "hexaspot-heltec-v3",
+ "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/heltec-wifi-lora-32-v3",
+ "Description": "Heltec V3 (Hexaspot)"
+ },
+ {
+ "ShortCode": "hexaspot-heltec-v4",
+ "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/heltec-wifi-lora-32-v4",
+ "Description": "Heltec V4 (Hexaspot)"
+ },
+ {
+ "ShortCode": "hexaspot-wireless-tracker-v2",
+ "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/heltec-wireless-tracker-v2",
+ "Description": "Heltec Wireless Tracker V2 (Hexaspot)"
+ }
+ ]
+}
\ No newline at end of file

diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt
index d610af483f..88f4713734 100644
--- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt
+++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt
@@ -23,6 +23,8 @@ import java.util.Locale
actual fun currentLocaleCode(): String = Locale.getDefault().language
+actual fun currentRegionCode(): String = Locale.getDefault().country
+
actual fun currentLocaleQualifier(): String {
val locale = Locale.getDefault()
val country = locale.country

diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt
index 194478f18c..3ced2718a0 100644
--- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt
+++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt
@@ -28,6 +28,12 @@ expect fun getSystemMeasurementSystem(): MeasurementSystem
/** Returns the device's current locale as a 2-letter ISO 639-1 language code (e.g. "en", "es", "fr"). */
expect fun currentLocaleCode(): String
+/**
+ * Returns the device's current region as a 2-letter ISO 3166-1 alpha-2 country code (e.g. "US", "DE"), or an empty
+ * string when the region is unknown. Used to region-filter marketplace links.
+ */
+expect fun currentRegionCode(): String
+
/**
* Returns the device locale as a CMP resource qualifier string. Examples: "pt-rBR", "zh-rCN", "fr" (no region when not
* specified). Use this to construct locale-qualified file resource paths like "files-$qualifier/docs/...".

diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
index 4d3b1b3630..e04222f8d1 100644
--- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
+++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt
@@ -42,6 +42,8 @@ actual fun getSystemMeasurementSystem(): MeasurementSystem = MeasurementSystem.M
actual fun currentLocaleCode(): String = "en"
+actual fun currentRegionCode(): String = ""
+
actual fun currentLocaleQualifier(): String = "en"
actual fun String?.isValidAddress(): Boolean = false

diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
index 66f7dd07e7..0661dfab1f 100644
--- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
+++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt
@@ -90,6 +90,8 @@ actual fun getSystemMeasurementSystem(): MeasurementSystem =
actual fun currentLocaleCode(): String = Locale.getDefault().language
+actual fun currentRegionCode(): String = Locale.getDefault().country
+
actual fun currentLocaleQualifier(): String {
val locale = Locale.getDefault()
val country = locale.country

diff --git a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSourceImpl.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSourceImpl.kt
new file mode 100644
index 0000000000..18d6d71359
--- /dev/null
+++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSourceImpl.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+@file:OptIn(ExperimentalSerializationApi::class)
+
+package org.meshtastic.core.data.datasource
+
+import android.app.Application
+import co.touchlab.kermit.Logger
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromStream
+import org.koin.core.annotation.Single
+import org.meshtastic.core.model.MshToMarketplace
+import org.meshtastic.core.model.MshToRoute
+import org.meshtastic.core.model.MshToUrlsFile
+
+@Single
+class MshToLinksJsonDataSourceImpl(private val application: Application) : MshToLinksJsonDataSource {
+
+ // Tolerant parser: tolerate extra fields/trailing data so a stale bundled file never crashes the import.
+ private val json = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ exceptionsWithDebugInfo = false
+ }
+
+ // The bundled assets are immutable for the install's lifetime, so parse once and reuse — these are read on the
+ // node-detail flow's hot path (once per hardware emission).
+ private val routes: List<MshToRoute> by lazy {
+ runCatching { application.assets.open(URLS_ASSET).use { json.decodeFromStream<MshToUrlsFile>(it).routes } }
+ .onFailure { Logger.w(it) { "Unable to load $URLS_ASSET for device links" } }
+ .getOrDefault(emptyList())
+ }
+
+ private val marketplaces: Map<String, MshToMarketplace> by lazy {
+ runCatching {
+ application.assets.open(MARKETPLACES_ASSET).use {
+ json.decodeFromStream<Map<String, MshToMarketplace>>(it)
+ }
+ }
+ .onFailure {
+ Logger.w(it) { "Unable to load $MARKETPLACES_ASSET; marketplace links won't be region-filtered" }
+ }
+ .getOrDefault(emptyMap())
+ }
+
+ override fun loadRoutes(): List<MshToRoute> = routes
+
+ override fun loadMarketplaces(): Map<String, MshToMarketplace> = marketplaces
+
+ private companion object {
+ const val URLS_ASSET = "urls.json"
+ const val MARKETPLACES_ASSET = "marketplaces.json"
+ }
+}

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
index 852ac08989..03b3de2ba6 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt
@@ -47,4 +47,7 @@ class DeviceHardwareLocalDataSource(
withContext(dispatchers.io) { deviceHardwareDao.getByModelAndTarget(hwModel, target) }
suspend fun hasAnyEntries(): Boolean = withContext(dispatchers.io) { deviceHardwareDao.count() > 0 }
+
+ /** All known `platformioTarget` values — used to determine which msh.to links are vendor links. */
+ suspend fun getAllTargets(): List<String> = withContext(dispatchers.io) { deviceHardwareDao.getAllTargets() }
}

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceLinkLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceLinkLocalDataSource.kt
new file mode 100644
index 0000000000..f63bd85e5f
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceLinkLocalDataSource.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.datasource
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+import org.koin.core.annotation.Single
+import org.meshtastic.core.database.DatabaseProvider
+import org.meshtastic.core.database.entity.DeviceLinkEntity
+import org.meshtastic.core.di.CoroutineDispatchers
+
+@Single
+class DeviceLinkLocalDataSource(
+ private val dbManager: DatabaseProvider,
+ private val dispatchers: CoroutineDispatchers,
+) {
+ private val deviceLinkDao
+ get() = dbManager.currentDb.value.deviceLinkDao()
+
+ fun observeAll(): Flow<List<DeviceLinkEntity>> = deviceLinkDao.observeAll()
+
+ suspend fun getAll(): List<DeviceLinkEntity> = withContext(dispatchers.io) { deviceLinkDao.getAll() }
+
+ suspend fun upsertAll(links: List<DeviceLinkEntity>) =
+ withContext(dispatchers.io) { deviceLinkDao.upsertAll(links) }
+
+ suspend fun deleteNotIn(keep: List<String>) = withContext(dispatchers.io) { deviceLinkDao.deleteNotIn(keep) }
+
+ suspend fun deleteAll() = withContext(dispatchers.io) { deviceLinkDao.deleteAll() }
+
+ suspend fun count(): Int = withContext(dispatchers.io) { deviceLinkDao.count() }
+}

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSource.kt
new file mode 100644
index 0000000000..74a54acb38
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSource.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.datasource
+
+import org.meshtastic.core.model.MshToMarketplace
+import org.meshtastic.core.model.MshToRoute
+
+/** Reads the bundled msh.to link data: `urls.json` (short codes) and `marketplaces.json` (region metadata). */
+interface MshToLinksJsonDataSource {
+ /** Routes from the bundled `urls.json`, or empty if missing/malformed. */
+ fun loadRoutes(): List<MshToRoute>
+
+ /** Marketplace metadata from the bundled `marketplaces.json`, keyed by marketplace identifier. */
+ fun loadMarketplaces(): Map<String, MshToMarketplace>
+}

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
index 95755f1fc5..0c48d6a173 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt
@@ -37,6 +37,7 @@ import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import org.meshtastic.core.repository.DeviceHardwareRepository
+import org.meshtastic.core.repository.DeviceLinkRepository
@Single
class DeviceHardwareRepositoryImpl(
@@ -44,6 +45,7 @@ class DeviceHardwareRepositoryImpl(
private val localDataSource: DeviceHardwareLocalDataSource,
private val jsonDataSource: DeviceHardwareJsonDataSource,
private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource,
+ private val deviceLinkRepository: DeviceLinkRepository,
private val dispatchers: CoroutineDispatchers,
) : DeviceHardwareRepository {
@@ -136,6 +138,10 @@ class DeviceHardwareRepositoryImpl(
Logger.w {
"DeviceHardwareRepository: network refresh timed out after ${NETWORK_REFRESH_TIMEOUT_MS}ms"
}
+ } else {
+ // Reconcile msh.to links against the freshest catalog (isVendor + orphan pruning). Runs outside
+ // the network timeout so a deadline can't cancel it mid-write and leave links half-reconciled.
+ deviceLinkRepository.reconcile()
}
}
.onFailure { e -> Logger.w(e) { "DeviceHardwareRepository: network refresh failed" } }

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcher.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcher.kt
new file mode 100644
index 0000000000..ce88e99178
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcher.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.repository
+
+import org.meshtastic.core.model.DeviceLink
+
+/**
+ * Pure matching logic for associating msh.to [DeviceLink]s with a device's `platformioTarget`. Ported from the
+ * Meshtastic-Apple `DeviceLinksSection` (multi-tier matching: exact vendor, product variant, marketplace), so the two
+ * platforms surface the same links.
+ */
+object DeviceLinkMatcher {
+
+ /**
+ * Links relevant to [target], region-filtered and sorted with vendor/variant links first.
+ *
+ * @param links all imported links.
+ * @param marketplaceKeys known marketplace identifiers (from `marketplaces.json`).
+ * @param deviceTargets all known device `platformioTarget`s — used to exclude other devices' links.
+ * @param target the viewed device's `platformioTarget`.
+ * @param region the user's ISO 3166-1 alpha-2 region for marketplace filtering.
+ */
+ fun match(
+ links: List<DeviceLink>,
+ marketplaceKeys: Set<String>,
+ deviceTargets: Set<String>,
+ target: String,
+ region: String,
+ ): List<DeviceLink> {
+ val variants = buildTargetVariants(target)
+ return links
+ .filter { link -> matches(link, marketplaceKeys, deviceTargets, target, variants, region) }
+ .sortedByDescending { it.isVendor || !isMarketplaceLink(it.shortCode, marketplaceKeys) }
+ }
+
+ @Suppress("ReturnCount")
+ private fun matches(
+ link: DeviceLink,
+ marketplaceKeys: Set<String>,
+ deviceTargets: Set<String>,
+ target: String,
+ variants: List<String>,
+ region: String,
+ ): Boolean {
+ val code = link.shortCode
+
+ // Exact vendor match always wins.
+ if (code == target) return true
+
+ // A vendor link for a different device is never shown here.
+ if (link.isVendor && code != target) return false
+
+ // Variant/marketplace-suffix: "<target>-..." or "<target>_...".
+ val matchesPrefix = variants.any { code.startsWith("${it}_") || code.startsWith("$it-") }
+
+ // Known marketplace prefix: "<marketplace>-<target>" or "<marketplace>_<target>".
+ val matchesMarketplacePrefix =
+ variants.any { variant -> marketplaceKeys.any { mp -> code == "$mp-$variant" || code == "${mp}_$variant" } }
+
+ if (!matchesPrefix && !matchesMarketplacePrefix) return false
+
+ // A prefix hit that is itself a different device's target belongs to that device, not this one.
+ if (matchesPrefix && code in deviceTargets && code != target) return false
+
+ // Region filter: null regions = vendor/variant (always), empty = worldwide, else must include the region.
+ val regions = link.regions ?: return true
+ if (regions.isEmpty()) return true
+ return region in regions
+ }
+
+ /** True when [code] carries a known marketplace prefix or suffix. */
+ fun isMarketplaceLink(code: String, marketplaceKeys: Set<String>): Boolean =
+ marketplaceKeyFor(code, marketplaceKeys) != null
+
+ /**
+ * The marketplace identifier [code] belongs to (as a delimiter-bounded prefix `mp-`/`mp_` or suffix `-mp`/`_mp`),
+ * or `null` if none. This is the single source of truth for "is this a marketplace link" — used for import-time
+ * region tagging, sort ordering, and UI prominence — so the classifications never disagree. Delimiter bounds avoid
+ * mis-tagging codes that merely begin with a marketplace name (e.g. `muziworks` is NOT `muzi`).
+ */
+ fun marketplaceKeyFor(code: String, marketplaceKeys: Set<String>): String? = marketplaceKeys.firstOrNull { mp ->
+ code.startsWith("$mp-") || code.startsWith("${mp}_") || code.endsWith("-$mp") || code.endsWith("_$mp")
+ }
+
+ /**
+ * Alternate target strings for matching. Strips a leading `rak` (e.g. `rak4631` → `4631`) to absorb msh.to naming
+ * inconsistencies like `rokland-4631`.
+ */
+ fun buildTargetVariants(target: String): List<String> {
+ val variants = mutableListOf(target)
+ if (target.startsWith("rak")) {
+ val stripped = target.removePrefix("rak")
+ if (stripped.isNotEmpty()) variants.add(stripped)
+ }
+ return variants
+ }
+}

diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImpl.kt
new file mode 100644
index 0000000000..b0cf2d23f4
--- /dev/null
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImpl.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.repository
+
+import co.touchlab.kermit.Logger
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emitAll
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.koin.core.annotation.Single
+import org.meshtastic.core.common.util.safeCatching
+import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
+import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource
+import org.meshtastic.core.data.datasource.MshToLinksJsonDataSource
+import org.meshtastic.core.database.entity.asEntity
+import org.meshtastic.core.database.entity.asExternalModel
+import org.meshtastic.core.model.DeviceLink
+import org.meshtastic.core.model.MshToMarketplace
+import org.meshtastic.core.repository.DeviceLinkRepository
+
+@Single
+class DeviceLinkRepositoryImpl(
+ private val jsonDataSource: MshToLinksJsonDataSource,
+ private val localDataSource: DeviceLinkLocalDataSource,
+ private val deviceHardwareLocalDataSource: DeviceHardwareLocalDataSource,
+) : DeviceLinkRepository {
+
+ /** Guards the import so concurrent collectors don't run it more than once at a time. */
+ private val importMutex = Mutex()
+
+ override suspend fun ensureImported() {
+ if (localDataSource.count() > 0) return
+ importMutex.withLock { if (localDataSource.count() == 0) doImport() }
+ }
+
+ override suspend fun reconcile() {
+ importMutex.withLock { doImport() }
+ }
+
+ override suspend fun getLinksForTarget(platformioTarget: String, regionCode: String): List<DeviceLink> {
+ if (platformioTarget.isBlank()) return emptyList()
+ ensureImported()
+ val links = localDataSource.getAll().map { it.asExternalModel() }
+ val marketplaceKeys = jsonDataSource.loadMarketplaces().keys
+ val deviceTargets = deviceHardwareLocalDataSource.getAllTargets().toSet()
+ return DeviceLinkMatcher.match(
+ links = links,
+ marketplaceKeys = marketplaceKeys,
+ deviceTargets = deviceTargets,
+ target = platformioTarget,
+ region = regionCode,
+ )
+ }
+
+ override fun observeAllLinks(): Flow<List<DeviceLink>> = flow {
+ ensureImported()
+ emitAll(localDataSource.observeAll().map { entities -> entities.map { it.asExternalModel() } })
+ }
+
+ /** Loads bundled `urls.json`, classifies each short code, upserts, and prunes orphans. Mirrors Apple's import. */
+ private suspend fun doImport() {
+ safeCatching {
+ val routes = jsonDataSource.loadRoutes()
+ if (routes.isEmpty()) {
+ Logger.w { "DeviceLinkRepository: no routes in bundled urls.json; skipping import" }
+ return@safeCatching
+ }
+ val marketplaces = jsonDataSource.loadMarketplaces()
+ val deviceTargets = deviceHardwareLocalDataSource.getAllTargets().toSet()
+
+ val links =
+ routes.map { route ->
+ val isVendor = route.shortCode in deviceTargets
+ DeviceLink(
+ shortCode = route.shortCode,
+ originalUrl = route.originalUrl,
+ description = route.description,
+ isVendor = isVendor,
+ regions = if (isVendor) null else marketplaceRegions(route.shortCode, marketplaces),
+ )
+ }
+
+ localDataSource.upsertAll(links.map { it.asEntity() })
+ localDataSource.deleteNotIn(links.map { it.shortCode })
+ Logger.i { "DeviceLinkRepository: imported ${links.size} msh.to links" }
+ }
+ .onFailure { Logger.w(it) { "DeviceLinkRepository: device links import failed" } }
+ }
+
+ /**
+ * Shipping regions for a marketplace short code, or null when it is not a marketplace link. Uses the same
+ * delimiter-aware classifier as the matcher/UI so a code's classification (vendor/variant vs marketplace) is
+ * consistent everywhere — independent of the `match` hint in `marketplaces.json`, which is unreliable in practice
+ * (e.g. AliExpress is declared `suffix` yet most codes use the `aliexpress-<target>` prefix form).
+ */
+ private fun marketplaceRegions(code: String, marketplaces: Map<String, MshToMarketplace>): List<String>? =
+ DeviceLinkMatcher.marketplaceKeyFor(code, marketplaces.keys)?.let { marketplaces.getValue(it).regions }
+}

diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcherTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcherTest.kt
new file mode 100644
index 0000000000..b9b69edd2d
--- /dev/null
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcherTest.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.repository
+
+import org.meshtastic.core.model.DeviceLink
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+/**
+ * Tests for [DeviceLinkMatcher], grounded in the acceptance scenarios of the Meshtastic-Apple `010-device-mshto-links`
+ * spec. Mirrors the as-built `DeviceLinksSection` matching (platformioTarget, not hwModelSlug).
+ */
+class DeviceLinkMatcherTest {
+
+ private val marketplaceKeys = setOf("rokland", "hexaspot", "aliexpress", "amazon", "tindie", "muzi")
+
+ private val deviceTargets =
+ setOf("rak4631", "heltec-v3", "seeed_solar_node", "tbeam", "rak4631_nomadstar_meteor_pro")
+
+ private fun link(shortCode: String, isVendor: Boolean = false, regions: List<String>? = null) = DeviceLink(
+ shortCode = shortCode,
+ originalUrl = "https://example.com/$shortCode",
+ isVendor = isVendor,
+ regions = regions,
+ )
+
+ private fun match(links: List<DeviceLink>, target: String, region: String = "US") =
+ DeviceLinkMatcher.match(links, marketplaceKeys, deviceTargets, target, region).map { it.shortCode }
+
+ @Test
+ fun exactVendorMatchIsIncluded() {
+ val result = match(listOf(link("heltec-v3", isVendor = true)), target = "heltec-v3")
+ assertEquals(listOf("heltec-v3"), result)
+ }
+
+ @Test
+ fun foreignVendorLinkIsExcluded() {
+ // Scenario 5: rak4631_nomadstar_meteor_pro (a different device's target) must NOT show for rak4631.
+ val result =
+ match(
+ listOf(link("rak4631", isVendor = true), link("rak4631_nomadstar_meteor_pro", isVendor = true)),
+ target = "rak4631",
+ )
+ assertEquals(listOf("rak4631"), result)
+ }
+
+ @Test
+ fun productVariantIsIncludedAndProminent() {
+ val result = match(listOf(link("rak4631_epaper")), target = "rak4631")
+ assertEquals(listOf("rak4631_epaper"), result)
+ }
+
+ @Test
+ fun marketplaceLinkIsRegionFiltered() {
+ val links = listOf(link("rokland-rak4631", regions = listOf("US", "CA")))
+ assertEquals(listOf("rokland-rak4631"), match(links, target = "rak4631", region = "US"))
+ assertEquals(emptyList(), match(links, target = "rak4631", region = "DE"))
+ }
+
+ @Test
+ fun rakPrefixIsStrippedForMarketplaceVariantMatch() {
+ // "rokland-4631" should match device "rak4631" via the rak-stripped variant "4631".
+ val result = match(listOf(link("rokland-4631", regions = listOf("US"))), target = "rak4631", region = "US")
+ assertEquals(listOf("rokland-4631"), result)
+ }
+
+ @Test
+ fun worldwideMarketplaceShowsRegardlessOfRegion() {
+ val links = listOf(link("rak4631_aliexpress", regions = emptyList()))
+ assertEquals(listOf("rak4631_aliexpress"), match(links, target = "rak4631", region = "ZZ"))
+ }
+
+ @Test
+ fun unrelatedLinksProduceEmptyResult() {
+ val links =
+ listOf(
+ link("github"),
+ link("heltec-v3", isVendor = true),
+ link("rokland-heltec-v3", regions = listOf("US")),
+ )
+ assertEquals(emptyList(), match(links, target = "tbeam"))
+ }
+
+ @Test
+ fun anotherDevicesTargetIsNotMatchedAsVariant() {
+ // "rak4631_nomadstar_meteor_pro" prefix-matches "rak4631_" but is itself a device target → excluded.
+ val result = match(listOf(link("rak4631_nomadstar_meteor_pro")), target = "rak4631")
+ assertEquals(emptyList(), result)
+ }
+
+ @Test
+ fun vendorAndVariantSortBeforeMarketplace() {
+ val links =
+ listOf(
+ link("rak4631_aliexpress", regions = emptyList()),
+ link("rak4631", isVendor = true),
+ link("rokland-rak4631", regions = listOf("US")),
+ link("rak4631_epaper"),
+ )
+ val result = match(links, target = "rak4631", region = "US")
+ // Vendor + variant first (order among them preserved from input), marketplace links after.
+ assertEquals(listOf("rak4631", "rak4631_epaper", "rak4631_aliexpress", "rokland-rak4631"), result)
+ }
+
+ @Test
+ fun buildTargetVariantsStripsRakPrefix() {
+ assertEquals(listOf("rak4631", "4631"), DeviceLinkMatcher.buildTargetVariants("rak4631"))
+ assertEquals(listOf("heltec-v3"), DeviceLinkMatcher.buildTargetVariants("heltec-v3"))
+ // Bare "rak" strips to empty and is not added.
+ assertEquals(listOf("rak"), DeviceLinkMatcher.buildTargetVariants("rak"))
+ }
+
+ @Test
+ fun isMarketplaceLinkDetectsPrefixAndSuffix() {
+ assertTrue(DeviceLinkMatcher.isMarketplaceLink("rokland-rak4631", marketplaceKeys))
+ assertTrue(DeviceLinkMatcher.isMarketplaceLink("heltec-v3_aliexpress", marketplaceKeys))
+ assertFalse(DeviceLinkMatcher.isMarketplaceLink("heltec-v3", marketplaceKeys))
+ }
+
+ @Test
+ fun marketplaceKeyForUsesDelimiterBounds() {
+ // Both prefix and suffix forms resolve to their marketplace...
+ assertEquals("rokland", DeviceLinkMatcher.marketplaceKeyFor("rokland-rak4631", marketplaceKeys))
+ assertEquals("aliexpress", DeviceLinkMatcher.marketplaceKeyFor("aliexpress-rak1921", marketplaceKeys))
+ assertEquals("aliexpress", DeviceLinkMatcher.marketplaceKeyFor("rak4631_aliexpress", marketplaceKeys))
+ // ...but a code that merely begins with a marketplace name is NOT that marketplace.
+ assertNull(DeviceLinkMatcher.marketplaceKeyFor("muziworks", marketplaceKeys))
+ assertNull(DeviceLinkMatcher.marketplaceKeyFor("heltec-v3", marketplaceKeys))
+ }
+}

diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt
new file mode 100644
index 0000000000..a76c648f2c
--- /dev/null
+++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt
@@ -0,0 +1,162 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.data.repository
+
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
+import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource
+import org.meshtastic.core.data.datasource.MshToLinksJsonDataSource
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.model.MshToMarketplace
+import org.meshtastic.core.model.MshToRoute
+import org.meshtastic.core.model.NetworkDeviceHardware
+import org.meshtastic.core.testing.FakeDatabaseProvider
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class DeviceLinkRepositoryImplTest {
+
+ private class FakeMshToLinksJsonDataSource(
+ var routes: List<MshToRoute>,
+ var marketplaces: Map<String, MshToMarketplace>,
+ ) : MshToLinksJsonDataSource {
+ override fun loadRoutes(): List<MshToRoute> = routes
+
+ override fun loadMarketplaces(): Map<String, MshToMarketplace> = marketplaces
+ }
+
+ private val dispatcher = UnconfinedTestDispatcher()
+ private val dispatchers = CoroutineDispatchers(main = dispatcher, io = dispatcher, default = dispatcher)
+
+ private lateinit var dbProvider: FakeDatabaseProvider
+ private lateinit var linkLocal: DeviceLinkLocalDataSource
+ private lateinit var hardwareLocal: DeviceHardwareLocalDataSource
+ private lateinit var json: FakeMshToLinksJsonDataSource
+ private lateinit var repository: DeviceLinkRepositoryImpl
+
+ private val marketplaces =
+ mapOf(
+ "rokland" to MshToMarketplace(regions = listOf("US"), match = "prefix"),
+ "aliexpress" to MshToMarketplace(regions = emptyList(), match = "suffix"),
+ )
+
+ private fun route(shortCode: String) =
+ MshToRoute(shortCode = shortCode, originalUrl = "https://example.com/$shortCode", description = shortCode)
+
+ @BeforeTest
+ fun setup() {
+ dbProvider = FakeDatabaseProvider()
+ linkLocal = DeviceLinkLocalDataSource(dbProvider, dispatchers)
+ hardwareLocal = DeviceHardwareLocalDataSource(dbProvider, dispatchers)
+ json =
+ FakeMshToLinksJsonDataSource(
+ routes =
+ listOf(route("rak4631"), route("rokland-rak4631"), route("rak4631_aliexpress"), route("github")),
+ marketplaces = marketplaces,
+ )
+ repository = DeviceLinkRepositoryImpl(json, linkLocal, hardwareLocal)
+ }
+
+ @AfterTest fun tearDown() = dbProvider.close()
+
+ private suspend fun seedDeviceTargets(vararg targets: String) {
+ hardwareLocal.insertAllDeviceHardware(
+ targets.mapIndexed { i, t -> NetworkDeviceHardware(hwModel = i + 1, platformioTarget = t) },
+ )
+ }
+
+ @Test
+ fun importClassifiesVendorAndMarketplaceLinks() = runTest(dispatcher) {
+ seedDeviceTargets("rak4631", "heltec-v3")
+ repository.reconcile()
+
+ val byCode = linkLocal.getAll().associateBy { it.shortCode }
+ assertEquals(4, byCode.size)
+
+ // rak4631 is a known device target → vendor, no regions.
+ assertTrue(byCode.getValue("rak4631").isVendor)
+ assertNull(byCode.getValue("rak4631").regions)
+
+ // rokland-rak4631 → prefix marketplace, region-tagged.
+ assertTrue(!byCode.getValue("rokland-rak4631").isVendor)
+ assertEquals(listOf("US"), byCode.getValue("rokland-rak4631").regions)
+
+ // rak4631_aliexpress → suffix marketplace, worldwide (empty regions).
+ assertEquals(emptyList(), byCode.getValue("rak4631_aliexpress").regions)
+
+ // github → neither vendor nor marketplace, null regions.
+ assertTrue(!byCode.getValue("github").isVendor)
+ assertNull(byCode.getValue("github").regions)
+ }
+
+ @Test
+ fun reconcilePrunesOrphanedShortCodes() = runTest(dispatcher) {
+ seedDeviceTargets("rak4631")
+ repository.reconcile()
+ assertEquals(4, linkLocal.count())
+
+ // Drop "github" from the bundled file and reconcile again.
+ json.routes = json.routes.filterNot { it.shortCode == "github" }
+ repository.reconcile()
+
+ val codes = linkLocal.getAll().map { it.shortCode }.toSet()
+ assertEquals(setOf("rak4631", "rokland-rak4631", "rak4631_aliexpress"), codes)
+ }
+
+ @Test
+ fun aliexpressPrefixFormIsClassifiedAsWorldwideMarketplace() = runTest(dispatcher) {
+ // AliExpress is declared match="suffix" yet most bundled codes use the `aliexpress-<target>` prefix form;
+ // import must still classify it as a (worldwide) marketplace link, not a null-region variant.
+ json.routes = listOf(route("rak4631"), route("aliexpress-rak4631"))
+ seedDeviceTargets("rak4631")
+ repository.reconcile()
+
+ assertEquals(emptyList(), linkLocal.getAll().single { it.shortCode == "aliexpress-rak4631" }.regions)
+ }
+
+ @Test
+ fun bareMarketplaceNamePrefixIsNotMistagged() = runTest(dispatcher) {
+ // "muziworks" merely begins with "muzi" — delimiter bounds must keep it from inheriting muzi's regions.
+ json =
+ FakeMshToLinksJsonDataSource(
+ routes = listOf(route("muziworks")),
+ marketplaces = mapOf("muzi" to MshToMarketplace(regions = listOf("US"), match = "prefix")),
+ )
+ repository = DeviceLinkRepositoryImpl(json, linkLocal, hardwareLocal)
+ seedDeviceTargets("rak4631")
+ repository.reconcile()
+
+ assertNull(linkLocal.getAll().single().regions)
+ }
+
+ @Test
+ fun ensureImportedSeedsOnlyWhenEmpty() = runTest(dispatcher) {
+ seedDeviceTargets("rak4631")
+ repository.ensureImported()
+ assertEquals(4, linkLocal.count())
+
+ // A second ensureImported with a larger bundled file must NOT re-import (table already populated).
+ json.routes = json.routes + route("new-code")
+ repository.ensureImported()
+ assertEquals(4, linkLocal.count())
+ }
+}

diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/41.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/41.json
new file mode 100644
index 0000000000..4de2f4048a
--- /dev/null
+++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/41.json
@@ -0,0 +1,1142 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 41,
+ "identityHash": "4c27816eb6e2b8336fd9ca69b9cd373b",
+ "entities": [
+ {
+ "tableName": "my_node",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "model",
+ "columnName": "model",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "firmwareVersion",
+ "columnName": "firmwareVersion",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "couldUpdate",
+ "columnName": "couldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "shouldUpdate",
+ "columnName": "shouldUpdate",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentPacketId",
+ "columnName": "currentPacketId",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "messageTimeoutMsec",
+ "columnName": "messageTimeoutMsec",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "minAppVersion",
+ "columnName": "minAppVersion",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "maxChannels",
+ "columnName": "maxChannels",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasWifi",
+ "columnName": "hasWifi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "deviceId",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "pioEnv",
+ "columnName": "pioEnv",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum"
+ ]
+ }
+ },
+ {
+ "tableName": "nodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "user",
+ "columnName": "user",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longName",
+ "columnName": "long_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "shortName",
+ "columnName": "short_name",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "latitude",
+ "columnName": "latitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "longitude",
+ "columnName": "longitude",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastHeard",
+ "columnName": "last_heard",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceTelemetry",
+ "columnName": "device_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "viaMqtt",
+ "columnName": "via_mqtt",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hops_away",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isFavorite",
+ "columnName": "is_favorite",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isIgnored",
+ "columnName": "is_ignored",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "isMuted",
+ "columnName": "is_muted",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "environmentTelemetry",
+ "columnName": "environment_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "powerTelemetry",
+ "columnName": "power_metrics",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "airQualityTelemetry",
+ "columnName": "air_quality_metrics",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ },
+ {
+ "fieldPath": "paxcounter",
+ "columnName": "paxcounter",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "notes",
+ "columnName": "notes",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "manuallyVerified",
+ "columnName": "manually_verified",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "nodeStatus",
+ "columnName": "node_status",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastTransport",
+ "columnName": "last_transport",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_nodes_last_heard",
+ "unique": false,
+ "columnNames": [
+ "last_heard"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)"
+ },
+ {
+ "name": "index_nodes_short_name",
+ "unique": false,
+ "columnNames": [
+ "short_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)"
+ },
+ {
+ "name": "index_nodes_long_name",
+ "unique": false,
+ "columnNames": [
+ "long_name"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)"
+ },
+ {
+ "name": "index_nodes_hops_away",
+ "unique": false,
+ "columnNames": [
+ "hops_away"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)"
+ },
+ {
+ "name": "index_nodes_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)"
+ },
+ {
+ "name": "index_nodes_last_heard_is_favorite",
+ "unique": false,
+ "columnNames": [
+ "last_heard",
+ "is_favorite"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)"
+ },
+ {
+ "name": "index_nodes_public_key",
+ "unique": false,
+ "columnNames": [
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '')",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "port_num",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_time",
+ "columnName": "received_time",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "read",
+ "columnName": "read",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "data",
+ "columnName": "data",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ },
+ {
+ "fieldPath": "filtered",
+ "columnName": "filtered",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true,
+ "defaultValue": "''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_packet_myNodeNum",
+ "unique": false,
+ "columnNames": [
+ "myNodeNum"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)"
+ },
+ {
+ "name": "index_packet_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ },
+ {
+ "name": "index_packet_contact_key",
+ "unique": false,
+ "columnNames": [
+ "contact_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)"
+ },
+ {
+ "name": "index_packet_contact_key_port_num_received_time",
+ "unique": false,
+ "columnNames": [
+ "contact_key",
+ "port_num",
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)"
+ },
+ {
+ "name": "index_packet_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ },
+ {
+ "name": "index_packet_received_time",
+ "unique": false,
+ "columnNames": [
+ "received_time"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)"
+ },
+ {
+ "name": "index_packet_filtered",
+ "unique": false,
+ "columnNames": [
+ "filtered"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)"
+ },
+ {
+ "name": "index_packet_read",
+ "unique": false,
+ "columnNames": [
+ "read"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)"
+ }
+ ]
+ },
+ {
+ "tableName": "packet_fts",
+ "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)",
+ "fields": [
+ {
+ "fieldPath": "messageText",
+ "columnName": "message_text",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": []
+ },
+ "ftsVersion": "FTS5",
+ "ftsOptions": {
+ "tokenizer": "unicode61",
+ "tokenizerArgs": [],
+ "contentTable": "packet",
+ "languageIdColumnName": "",
+ "matchInfo": "FTS4",
+ "notIndexedColumns": [],
+ "prefixSizes": [],
+ "preferredOrder": "ASC",
+ "contentRowId": "",
+ "columnSize": true,
+ "detail": "FULL"
+ },
+ "contentSyncTriggers": [
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END",
+ "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END"
+ ]
+ },
+ {
+ "tableName": "contact_settings",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))",
+ "fields": [
+ {
+ "fieldPath": "contact_key",
+ "columnName": "contact_key",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "muteUntil",
+ "columnName": "muteUntil",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastReadMessageUuid",
+ "columnName": "last_read_message_uuid",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastReadMessageTimestamp",
+ "columnName": "last_read_message_timestamp",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "filteringDisabled",
+ "columnName": "filtering_disabled",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "contact_key"
+ ]
+ }
+ },
+ {
+ "tableName": "log",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message_type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "received_date",
+ "columnName": "received_date",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "raw_message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "fromNum",
+ "columnName": "from_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "portNum",
+ "columnName": "port_num",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "fromRadio",
+ "columnName": "from_radio",
+ "affinity": "BLOB",
+ "notNull": true,
+ "defaultValue": "x''"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "uuid"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_log_from_num",
+ "unique": false,
+ "columnNames": [
+ "from_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)"
+ },
+ {
+ "name": "index_log_port_num",
+ "unique": false,
+ "columnNames": [
+ "port_num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "quick_chat",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)",
+ "fields": [
+ {
+ "fieldPath": "uuid",
+ "columnName": "uuid",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "message",
+ "columnName": "message",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mode",
+ "columnName": "mode",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "uuid"
+ ]
+ }
+ },
+ {
+ "tableName": "reactions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))",
+ "fields": [
+ {
+ "fieldPath": "myNodeNum",
+ "columnName": "myNodeNum",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "replyId",
+ "columnName": "reply_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "emoji",
+ "columnName": "emoji",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "snr",
+ "columnName": "snr",
+ "affinity": "REAL",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "rssi",
+ "columnName": "rssi",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "hopsAway",
+ "columnName": "hopsAway",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "-1"
+ },
+ {
+ "fieldPath": "packetId",
+ "columnName": "packet_id",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "routingError",
+ "columnName": "routing_error",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relays",
+ "columnName": "relays",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "relayNode",
+ "columnName": "relay_node",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "to",
+ "columnName": "to",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "channel",
+ "columnName": "channel",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "0"
+ },
+ {
+ "fieldPath": "sfpp_hash",
+ "columnName": "sfpp_hash",
+ "affinity": "BLOB"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "myNodeNum",
+ "reply_id",
+ "user_id",
+ "emoji"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_reactions_reply_id",
+ "unique": false,
+ "columnNames": [
+ "reply_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)"
+ },
+ {
+ "name": "index_reactions_packet_id",
+ "unique": false,
+ "columnNames": [
+ "packet_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)"
+ }
+ ]
+ },
+ {
+ "tableName": "metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))",
+ "fields": [
+ {
+ "fieldPath": "num",
+ "columnName": "num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "proto",
+ "columnName": "proto",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_metadata_num",
+ "unique": false,
+ "columnNames": [
+ "num"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)"
+ }
+ ]
+ },
+ {
+ "tableName": "device_hardware",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))",
+ "fields": [
+ {
+ "fieldPath": "activelySupported",
+ "columnName": "actively_supported",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "architecture",
+ "columnName": "architecture",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "displayName",
+ "columnName": "display_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hasInkHud",
+ "columnName": "has_ink_hud",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hasMui",
+ "columnName": "has_mui",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "hwModel",
+ "columnName": "hwModel",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "hwModelSlug",
+ "columnName": "hw_model_slug",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "images",
+ "columnName": "images",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "partitionScheme",
+ "columnName": "partition_scheme",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "platformioTarget",
+ "columnName": "platformio_target",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requiresDfu",
+ "columnName": "requires_dfu",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "supportLevel",
+ "columnName": "support_level",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "tags",
+ "columnName": "tags",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "platformio_target"
+ ]
+ }
+ },
+ {
+ "tableName": "device_link",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `original_url` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, PRIMARY KEY(`short_code`))",
+ "fields": [
+ {
+ "fieldPath": "shortCode",
+ "columnName": "short_code",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "originalUrl",
+ "columnName": "original_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "linkDescription",
+ "columnName": "link_description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "isVendor",
+ "columnName": "is_vendor",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "regions",
+ "columnName": "regions",
+ "affinity": "TEXT"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "short_code"
+ ]
+ }
+ },
+ {
+ "tableName": "firmware_release",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "pageUrl",
+ "columnName": "page_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseNotes",
+ "columnName": "release_notes",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "zipUrl",
+ "columnName": "zip_url",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastUpdated",
+ "columnName": "last_updated",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "releaseType",
+ "columnName": "release_type",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ }
+ },
+ {
+ "tableName": "traceroute_node_position",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "logUuid",
+ "columnName": "log_uuid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestId",
+ "columnName": "request_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nodeNum",
+ "columnName": "node_num",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "log_uuid",
+ "node_num"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_traceroute_node_position_log_uuid",
+ "unique": false,
+ "columnNames": [
+ "log_uuid"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)"
+ },
+ {
+ "name": "index_traceroute_node_position_request_id",
+ "unique": false,
+ "columnNames": [
+ "request_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "log",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "log_uuid"
+ ],
+ "referencedColumns": [
+ "uuid"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c27816eb6e2b8336fd9ca69b9cd373b')"
+ ]
+ }
+}
\ No newline at end of file

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
index 82eb8b0039..205d00f73f 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
@@ -25,6 +25,7 @@ import androidx.room3.TypeConverters
import androidx.room3.migration.AutoMigrationSpec
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
+import org.meshtastic.core.database.dao.DeviceLinkDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
@@ -33,6 +34,7 @@ import org.meshtastic.core.database.dao.QuickChatActionDao
import org.meshtastic.core.database.dao.TracerouteNodePositionDao
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.DeviceHardwareEntity
+import org.meshtastic.core.database.entity.DeviceLinkEntity
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
@@ -57,6 +59,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
ReactionEntity::class,
MetadataEntity::class,
DeviceHardwareEntity::class,
+ DeviceLinkEntity::class,
FirmwareReleaseEntity::class,
TracerouteNodePositionEntity::class,
],
@@ -99,8 +102,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 37, to = 38),
AutoMigration(from = 38, to = 39),
AutoMigration(from = 39, to = 40),
+ AutoMigration(from = 40, to = 41),
],
- version = 40,
+ version = 41,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -117,6 +121,8 @@ abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun deviceHardwareDao(): DeviceHardwareDao
+ abstract fun deviceLinkDao(): DeviceLinkDao
+
abstract fun firmwareReleaseDao(): FirmwareReleaseDao
abstract fun tracerouteNodePositionDao(): TracerouteNodePositionDao

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
index 01f61e3ee8..ae188d1785 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt
@@ -36,6 +36,9 @@ interface DeviceHardwareDao {
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel AND platformio_target = :target")
suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity?
+ @Query("SELECT platformio_target FROM device_hardware")
+ suspend fun getAllTargets(): List<String>
+
@Query("SELECT COUNT(*) FROM device_hardware")
suspend fun count(): Int

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceLinkDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceLinkDao.kt
new file mode 100644
index 0000000000..8221a8d6ae
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceLinkDao.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.database.dao
+
+import androidx.room3.Dao
+import androidx.room3.Query
+import androidx.room3.Upsert
+import kotlinx.coroutines.flow.Flow
+import org.meshtastic.core.database.entity.DeviceLinkEntity
+
+@Dao
+interface DeviceLinkDao {
+ @Upsert suspend fun upsertAll(links: List<DeviceLinkEntity>)
+
+ @Query("SELECT * FROM device_link ORDER BY short_code")
+ fun observeAll(): Flow<List<DeviceLinkEntity>>
+
+ @Query("SELECT * FROM device_link")
+ suspend fun getAll(): List<DeviceLinkEntity>
+
+ @Query("DELETE FROM device_link WHERE short_code NOT IN (:keep)")
+ suspend fun deleteNotIn(keep: List<String>)
+
+ @Query("DELETE FROM device_link")
+ suspend fun deleteAll()
+
+ @Query("SELECT COUNT(*) FROM device_link")
+ suspend fun count(): Int
+}

diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceLinkEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceLinkEntity.kt
new file mode 100644
index 0000000000..91a9dde6bf
--- /dev/null
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceLinkEntity.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.database.entity
+
+import androidx.room3.ColumnInfo
+import androidx.room3.Entity
+import androidx.room3.PrimaryKey
+import kotlinx.serialization.Serializable
+import org.meshtastic.core.model.DeviceLink
+
+/** A msh.to short-link, upserted from the bundled `urls.json` during the device-hardware refresh cycle. */
+@Serializable
+@Entity(tableName = "device_link")
+data class DeviceLinkEntity(
+ @PrimaryKey @ColumnInfo(name = "short_code") val shortCode: String,
+ @ColumnInfo(name = "original_url") val originalUrl: String,
+ @ColumnInfo(name = "link_description") val linkDescription: String? = null,
+ @ColumnInfo(name = "is_vendor") val isVendor: Boolean = false,
+ val regions: List<String>? = null,
+)
+
+fun DeviceLink.asEntity() = DeviceLinkEntity(
+ shortCode = shortCode,
+ originalUrl = originalUrl,
+ linkDescription = description,
+ isVendor = isVendor,
+ regions = regions,
+)
+
+fun DeviceLinkEntity.asExternalModel() = DeviceLink(
+ shortCode = shortCode,
+ originalUrl = originalUrl,
+ description = linkDescription,
+ isVendor = isVendor,
+ regions = regions,
+)

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceLink.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceLink.kt
new file mode 100644
index 0000000000..20b9197a34
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceLink.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model
+
+import kotlinx.serialization.Serializable
+
+/**
+ * A msh.to short-link associated with a piece of hardware. Imported from the bundled `urls.json` (sourced from the
+ * meshtastic/msh.to repo). Every link resolves through the msh.to redirect service.
+ *
+ * @param shortCode the msh.to short code, e.g. `rak_wismeshtag`, `rokland-heltec-v3`.
+ * @param originalUrl the destination URL recorded in `urls.json` (informational; the app links to msh.to).
+ * @param description human-readable label shown to the user.
+ * @param isVendor true when [shortCode] is itself a known device `platformioTarget` (the primary vendor link).
+ * @param regions marketplace shipping regions (ISO 3166-1 alpha-2). `null` = vendor/variant (not region-filtered);
+ * empty = worldwide marketplace; non-empty = limited to the listed countries.
+ */
+@Serializable
+data class DeviceLink(
+ val shortCode: String,
+ val originalUrl: String,
+ val description: String? = null,
+ val isVendor: Boolean = false,
+ val regions: List<String>? = null,
+) {
+ /** The user-facing link, routed through the msh.to redirect service. */
+ val url: String
+ get() = "https://msh.to/$shortCode"
+}

diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MshToLinks.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MshToLinks.kt
new file mode 100644
index 0000000000..241a88d4b6
--- /dev/null
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MshToLinks.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.model
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/** Root of the bundled `urls.json` file (imported as-is from the meshtastic/msh.to repo). */
+@Serializable data class MshToUrlsFile(@SerialName("Routes") val routes: List<MshToRoute> = emptyList())
+
+/** A single short-code route in `urls.json`. */
+@Serializable
+data class MshToRoute(
+ @SerialName("ShortCode") val shortCode: String,
+ @SerialName("OriginalUrl") val originalUrl: String,
+ @SerialName("Description") val description: String? = null,
+)
+
+/**
+ * Marketplace metadata from the app-maintained `marketplaces.json`. Keyed by marketplace identifier (e.g. `rokland`,
+ * `aliexpress`).
+ *
+ * @param regions ISO 3166-1 alpha-2 shipping regions; empty = worldwide.
+ * @param match how the marketplace identifier appears in a short code: `"prefix"` (e.g. `rokland-heltec-v3`) or
+ * `"suffix"` (e.g. `heltec-v3_aliexpress`).
+ */
+@Serializable data class MshToMarketplace(val regions: List<String> = emptyList(), val match: String)

diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
index a0a93caf10..eeb3c3fddd 100644
--- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
+++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
@@ -170,6 +170,8 @@ sealed interface SettingsRoute : Route {
@Serializable data object NodeList : SettingsRoute
+ @Serializable data object DeviceLinks : SettingsRoute
+
@Serializable data object AppFunctionsSettings : SettingsRoute
// endregion

diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceLinkRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceLinkRepository.kt
new file mode 100644
index 0000000000..7bfe3a2210
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceLinkRepository.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.repository
+
+import kotlinx.coroutines.flow.Flow
+import org.meshtastic.core.common.util.currentRegionCode
+import org.meshtastic.core.model.DeviceLink
+
+/**
+ * Provides msh.to device links imported from the bundled `urls.json`. Mirrors the Meshtastic-Apple device-links
+ * feature: vendor, product-variant, and region-filtered marketplace links shown on the device hardware detail view,
+ * plus a full directory.
+ */
+interface DeviceLinkRepository {
+ /** Seeds the link table from the bundled JSON if it is empty (covers fresh install, data clear, radio switch). */
+ suspend fun ensureImported()
+
+ /** Re-imports the bundled JSON: upserts all links, recomputes `isVendor`, and prunes orphaned short codes. */
+ suspend fun reconcile()
+
+ /**
+ * Links for a device's [platformioTarget], region-filtered and sorted with vendor/variant links first. Returns an
+ * empty list when no links match.
+ */
+ suspend fun getLinksForTarget(platformioTarget: String, regionCode: String = currentRegionCode()): List<DeviceLink>
+
+ /** All imported links, sorted by short code — backs the Settings "Device Links" directory. */
+ fun observeAllLinks(): Flow<List<DeviceLink>>
+}

diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 134be20b65..2f5b28838b 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -204,6 +204,7 @@
<string name="codec2_sample_rate">CODEC2 sample rate</string>
<string name="coding_rate">Coding Rate</string>
<string name="collapse_chart">Collapse chart</string>
+ <string name="collapsed">Collapsed</string>
<string name="communicate_off_the_grid">Communicate off-the-grid with your friends and community without cell service.</string>
<!-- COMPASS -->
<string name="compass_bearing">Bearing: %1$s</string>
@@ -340,6 +341,9 @@
<string name="device_db_cache_limit">Device DB cache limit</string>
<string name="device_db_cache_limit_summary">Max device databases to keep on this phone</string>
<string name="device_gps">Device GPS</string>
+ <string name="device_links">Device Links</string>
+ <string name="device_links_i_want_one">I want one</string>
+ <string name="device_links_open_in_browser">Open in browser</string>
<string name="device_metrics_label_value">%1$s: %2$s</string>
<string name="device_metrics_log">Device Metrics</string>
<string name="device_metrics_numeric_value">%1$s</string>
@@ -457,6 +461,7 @@
<string name="event_welcome_open_sauce">Welcome to Open Sauce! 🔧</string>
<string name="exchange_position">Exchange position</string>
<string name="expand_chart">Expand chart</string>
+ <string name="expanded">Expanded</string>
<string name="expires">Expires</string>
<!-- EXPORT -->
<string name="export_configuration">Export configuration</string>

diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
index c877d43c75..2a1b0b3763 100644
--- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
+++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt
@@ -37,7 +37,10 @@ import org.koin.dsl.module
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
+import org.meshtastic.core.data.datasource.MshToLinksJsonDataSource
import org.meshtastic.core.model.BootloaderOtaQuirk
+import org.meshtastic.core.model.MshToMarketplace
+import org.meshtastic.core.model.MshToRoute
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.network.HttpClientDefaults
@@ -267,4 +270,12 @@ private fun desktopPlatformStubsModule() = module {
override fun loadBootloaderOtaQuirksFromJsonAsset(): List<BootloaderOtaQuirk> = emptyList()
}
}
+
+ single<MshToLinksJsonDataSource> {
+ object : MshToLinksJsonDataSource {
+ override fun loadRoutes(): List<MshToRoute> = emptyList()
+
+ override fun loadMarketplaces(): Map<String, MshToMarketplace> = emptyMap()
+ }
+ }
}

diff --git a/docs/en/user/nodes.md b/docs/en/user/nodes.md
index 4eb7a7fbc1..07ba15460b 100644
--- a/docs/en/user/nodes.md
+++ b/docs/en/user/nodes.md
@@ -2,7 +2,7 @@
title: Nodes
parent: User Guide
nav_order: 4
-last_updated: 2026-05-20
+last_updated: 2026-06-02
description: Browse, filter, and sort mesh nodes — view details, signal quality, roles, and quick actions.
aliases:
- node-list
@@ -140,6 +140,12 @@ Inline status indicators show key metrics at a glance:
| Last heard | ![Last heard](../../assets/screenshots/nodes_last_heard.png) |
| Distance | ![Distance](../../assets/screenshots/nodes_distance_info.png) |
+### Device Links ("I want one")
+
+When a node's hardware is recognized, the detail view shows a collapsible **"I want one"** section linking to places to buy or learn more about that device: the vendor's product page, product variants, and regional marketplace listings (such as AliExpress, Amazon, and supported retailers), filtered to your country. Each link opens through the `msh.to` redirect service. Devices with no matching links don't show the section.
+
+A full, browsable directory of every link is also available under **Settings → Device Links**.
+
## Related Topics
- [Node Metrics](node-metrics) — detailed telemetry dashboards for each node

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceLinksSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceLinksSection.kt
new file mode 100644
index 0000000000..316cab7db4
--- /dev/null
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceLinksSection.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.component
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.semantics.stateDescription
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.model.DeviceLink
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.collapsed
+import org.meshtastic.core.resources.device_links_i_want_one
+import org.meshtastic.core.resources.device_links_open_in_browser
+import org.meshtastic.core.resources.expanded
+import org.meshtastic.core.ui.icon.ExpandLess
+import org.meshtastic.core.ui.icon.ExpandMore
+import org.meshtastic.core.ui.icon.Language
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+
+/**
+ * Collapsible "I want one" section listing msh.to vendor/variant and marketplace links for the viewed device. Renders
+ * nothing when there are no matching links. Ported from the Meshtastic-Apple `DeviceLinksSection`.
+ */
+@Composable
+fun DeviceLinksSection(links: List<DeviceLink>, modifier: Modifier = Modifier) {
+ if (links.isEmpty()) return
+
+ var expanded by rememberSaveable { mutableStateOf(false) }
+ val title = stringResource(Res.string.device_links_i_want_one)
+ val expandStateDescription = stringResource(if (expanded) Res.string.expanded else Res.string.collapsed)
+
+ ElevatedCard(
+ modifier = modifier.fillMaxWidth(),
+ colors = CardDefaults.elevatedCardColors(containerColor = colorScheme.surfaceContainerHigh),
+ shape = MaterialTheme.shapes.extraLarge,
+ ) {
+ Column(modifier = Modifier.padding(vertical = 16.dp).animateContentSize()) {
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .clickable(role = Role.Button) { expanded = !expanded }
+ .semantics { stateDescription = expandStateDescription }
+ .padding(horizontal = 20.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ color = colorScheme.primary,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f).semantics { heading() },
+ )
+ Icon(
+ imageVector = if (expanded) MeshtasticIcons.ExpandLess else MeshtasticIcons.ExpandMore,
+ contentDescription = null,
+ tint = colorScheme.primary,
+ )
+ }
+ if (expanded) {
+ links.forEach { DeviceLinkRow(it) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DeviceLinkRow(link: DeviceLink) {
+ val uriHandler = LocalUriHandler.current
+ // Vendor and product-variant links are emphasized; marketplace links (region-tagged) are quieter.
+ val prominent = link.isVendor || link.regions == null
+ val openLabel = stringResource(Res.string.device_links_open_in_browser)
+ val label = link.description ?: link.shortCode
+
+ Row(
+ modifier =
+ Modifier.fillMaxWidth()
+ .defaultMinSize(minHeight = 48.dp)
+ .clickable(role = Role.Button) { uriHandler.openUri(link.url) }
+ .padding(horizontal = 20.dp, vertical = 8.dp)
+ .semantics { contentDescription = "$openLabel: $label" },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = label,
+ style = if (prominent) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
+ fontWeight = if (prominent) FontWeight.SemiBold else FontWeight.Normal,
+ color = colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ )
+ Spacer(Modifier.width(8.dp))
+ Icon(
+ imageVector = MeshtasticIcons.Language,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = colorScheme.primary,
+ )
+ }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
index bc8f7f8c63..70b79be5be 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt
@@ -183,3 +183,30 @@ private fun NodeDetailsSectionWithDeviceHeroPreview() {
Surface { NodeDetailsSection(node = node, deviceHardware = deviceHardware, reportedTarget = "heltec-v3") }
}
}
+
+@PreviewLightDark
+@Composable
+private fun DeviceLinksSectionPreview() {
+ val links =
+ listOf(
+ org.meshtastic.core.model.DeviceLink(
+ shortCode = "heltec-v3",
+ originalUrl = "https://heltec.org",
+ description = "Heltec V3",
+ isVendor = true,
+ ),
+ org.meshtastic.core.model.DeviceLink(
+ shortCode = "rokland-heltec-v3",
+ originalUrl = "https://rokland.com",
+ description = "Rokland",
+ regions = listOf("US"),
+ ),
+ org.meshtastic.core.model.DeviceLink(
+ shortCode = "heltec-v3_aliexpress",
+ originalUrl = "https://aliexpress.com",
+ description = "AliExpress",
+ regions = emptyList(),
+ ),
+ )
+ AppTheme { Surface { DeviceLinksSection(links = links) } }
+}

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt
index a102bf08b8..357e0eca39 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt
@@ -41,6 +41,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.loading
import org.meshtastic.feature.node.component.AdministrationSection
import org.meshtastic.feature.node.component.DeviceActions
+import org.meshtastic.feature.node.component.DeviceLinksSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.model.NodeDetailAction
@@ -109,6 +110,9 @@ fun NodeDetailList(
reportedTarget = uiState.metricsState.reportedTarget,
)
}
+ if (uiState.metricsState.deviceLinks.isNotEmpty()) {
+ item { DeviceLinksSection(links = uiState.metricsState.deviceLinks) }
+ }
item {
DeviceActions(
node = node,

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
index 34c3a1b964..930479f81c 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt
@@ -22,16 +22,19 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Single
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
+import org.meshtastic.core.repository.DeviceLinkRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
@@ -59,6 +62,7 @@ constructor(
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
+ private val deviceLinkRepository: DeviceLinkRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) : GetNodeDetailsUseCase {
@@ -114,8 +118,8 @@ constructor(
IdentityGroup(ourNode, myInfo, profile)
}
- // 3. Device Hardware — non-blocking Flow derived from stable (hwModel, pioEnv) key.
- val hardwareFlow: Flow<DeviceHardware?> =
+ // 3. Device Hardware (+ msh.to links) — non-blocking Flow derived from stable (hwModel, pioEnv) key.
+ val hardwareAndLinksFlow: Flow<Pair<DeviceHardware?, List<DeviceLink>>> =
combine(nodeFlow, identityFlow) { node, identity ->
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
@@ -124,6 +128,13 @@ constructor(
.distinctUntilChanged()
.flatMapLatest { key -> deviceHardwareRepository.observeDeviceHardware(key.hwModel, key.target) }
.onStart { emit(null) }
+ .mapLatest { hw ->
+ val links =
+ hw?.platformioTarget
+ ?.takeIf { it.isNotBlank() }
+ ?.let { deviceLinkRepository.getLinksForTarget(it) } ?: emptyList()
+ hw to links
+ }
// 4. Metadata & Request Timestamps
val metadataFlow =
@@ -157,7 +168,7 @@ constructor(
identityFlow,
metadataFlow,
requestsFlow,
- hardwareFlow,
+ hardwareAndLinksFlow,
) { args: Array<Any?> ->
@Suppress("UNCHECKED_CAST")
val node = args[NODE_INDEX] as Node
@@ -165,7 +176,7 @@ constructor(
val identity = args[IDENTITY_INDEX] as IdentityGroup
val metadata = args[METADATA_INDEX] as MetadataGroup
val requests = args[REQUESTS_INDEX] as Pair<List<MeshLog>, List<MeshLog>>
- val hw = args[HARDWARE_INDEX] as DeviceHardware?
+ val (hw, deviceLinks) = args[HARDWARE_INDEX] as Pair<DeviceHardware?, List<DeviceLink>>
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
@@ -179,6 +190,7 @@ constructor(
node = node,
isLocal = isLocal,
deviceHardware = hw,
+ deviceLinks = deviceLinks,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
index f945daf842..7e89b66042 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt
@@ -18,6 +18,7 @@ package org.meshtastic.feature.node.model
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
+import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.proto.Config
@@ -42,6 +43,8 @@ data class MetricsState(
val neighborInfoResults: List<MeshLog> = emptyList(),
val positionLogs: List<Position> = emptyList(),
val deviceHardware: DeviceHardware? = null,
+ /** msh.to vendor/marketplace links for this device's hardware, region-filtered and sorted (vendor first). */
+ val deviceLinks: List<DeviceLink> = emptyList(),
val firmwareEdition: FirmwareEdition? = null,
val latestStableFirmware: FirmwareRelease = FirmwareRelease(),
val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(),

diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
index d6d74cd654..98c4dcfc56 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt
@@ -49,6 +49,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_functions_settings
import org.meshtastic.core.resources.app_functions_settings_summary
import org.meshtastic.core.resources.bottom_nav_settings
+import org.meshtastic.core.resources.device_links
import org.meshtastic.core.resources.export_configuration
import org.meshtastic.core.resources.filter_settings
import org.meshtastic.core.resources.help_and_documentation
@@ -60,6 +61,7 @@ import org.meshtastic.core.resources.wifi_devices
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
+import org.meshtastic.core.ui.icon.Device
import org.meshtastic.core.ui.icon.FilterList
import org.meshtastic.core.ui.icon.HelpOutline
import org.meshtastic.core.ui.icon.List
@@ -272,6 +274,12 @@ fun SettingsScreen(
}
}
+ ExpressiveSection(title = stringResource(Res.string.device_links)) {
+ ListItem(text = stringResource(Res.string.device_links), leadingIcon = MeshtasticIcons.Device) {
+ onNavigate(SettingsRoute.DeviceLinks)
+ }
+ }
+
if (appFunctionsAvailable) {
ExpressiveSection(title = stringResource(Res.string.app_functions_settings)) {
ListItem(

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryScreen.kt
new file mode 100644
index 0000000000..5b930ff1e3
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryScreen.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import org.jetbrains.compose.resources.stringResource
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.device_links
+import org.meshtastic.core.ui.component.ListItem
+import org.meshtastic.core.ui.component.MainAppBar
+import org.meshtastic.core.ui.icon.Language
+import org.meshtastic.core.ui.icon.MeshtasticIcons
+
+/** Directory of every imported msh.to short code. Tapping a row opens `msh.to/{shortCode}` in the browser. */
+@Composable
+fun DeviceLinkDirectoryScreen(
+ viewModel: DeviceLinkDirectoryViewModel,
+ onNavigateUp: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val links by viewModel.links.collectAsStateWithLifecycle()
+ val uriHandler = LocalUriHandler.current
+
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ MainAppBar(
+ title = stringResource(Res.string.device_links),
+ canNavigateUp = true,
+ onNavigateUp = onNavigateUp,
+ ourNode = null,
+ showNodeChip = false,
+ actions = {},
+ onClickChip = {},
+ )
+ },
+ ) { paddingValues ->
+ LazyColumn(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
+ items(links, key = { it.shortCode }) { link ->
+ ListItem(
+ text = link.description ?: link.shortCode,
+ supportingText = "msh.to/${link.shortCode}",
+ trailingIcon = MeshtasticIcons.Language,
+ onClick = { uriHandler.openUri(link.url) },
+ )
+ }
+ }
+ }
+}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryViewModel.kt
new file mode 100644
index 0000000000..f7afc93bb5
--- /dev/null
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryViewModel.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings
+
+import androidx.lifecycle.ViewModel
+import kotlinx.coroutines.flow.StateFlow
+import org.koin.core.annotation.KoinViewModel
+import org.meshtastic.core.model.DeviceLink
+import org.meshtastic.core.repository.DeviceLinkRepository
+import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
+
+/** Backs the Settings "Device Links" directory: all imported msh.to links, sorted by short code. */
+@KoinViewModel
+class DeviceLinkDirectoryViewModel(deviceLinkRepository: DeviceLinkRepository) : ViewModel() {
+ val links: StateFlow<List<DeviceLink>> = deviceLinkRepository.observeAllLinks().stateInWhileSubscribed(emptyList())
+}

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
index a38c3665f6..5f81667bb9 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt
@@ -33,6 +33,7 @@ import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.feature.settings.AboutScreen
import org.meshtastic.feature.settings.AdministrationScreen
import org.meshtastic.feature.settings.DeviceConfigurationScreen
+import org.meshtastic.feature.settings.DeviceLinkDirectoryScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.NodeListScreen
import org.meshtastic.feature.settings.SettingsViewModel
@@ -251,6 +252,12 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
)
}
+ entry<SettingsRoute.DeviceLinks> {
+ DeviceLinkDirectoryScreen(
+ viewModel = koinViewModel(),
+ onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
+ )
+ }
entry<SettingsRoute.AppFunctionsSettings> {
val viewModel: AppFunctionsSettingsViewModel = koinViewModel()
AppFunctionsSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })

diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
index 1036a08918..4c34562d94 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt
@@ -48,6 +48,7 @@ import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.device_db_cache_limit
import org.meshtastic.core.resources.device_db_cache_limit_summary
+import org.meshtastic.core.resources.device_links
import org.meshtastic.core.resources.help_and_documentation
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.modules_already_unlocked
@@ -62,6 +63,7 @@ import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.ChevronRight
+import org.meshtastic.core.ui.icon.Device
import org.meshtastic.core.ui.icon.FormatPaint
import org.meshtastic.core.ui.icon.HelpOutline
import org.meshtastic.core.ui.icon.Info
@@ -213,6 +215,12 @@ fun DesktopSettingsScreen(
}
}
+ ExpressiveSection(title = stringResource(Res.string.device_links)) {
+ ListItem(text = stringResource(Res.string.device_links), leadingIcon = MeshtasticIcons.Device) {
+ onNavigate(SettingsRoute.DeviceLinks)
+ }
+ }
+
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) {
onNavigate(WifiProvisionRoute.WifiProvision())

Served by rngit 1.5.0 - Generated in 0.38s